diff --git a/package-lock.json b/package-lock.json
index 69fa9aa..67eda50 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@unly/universal-language-detector": "^2.0.3",
+ "@xyflow/react": "^12.11.2",
"apexcharts": "^3.45.2",
"axios": "^1.9.0",
"dayjs": "^1.11.10",
@@ -65,7 +66,8 @@
"styled-components": "^6.1.8",
"sweetalert2": "^11.10.3",
"xlsx": "^0.18.5",
- "xlsx-js-style": "^1.2.0"
+ "xlsx-js-style": "^1.2.0",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@types/lodash": "^4.14.202",
@@ -83,7 +85,8 @@
"postcss": "^8.4.33",
"prettier": "3.1.1",
"tailwindcss": "^3.4.1",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^3.2.4"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -1929,6 +1932,448 @@
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
@@ -2275,9 +2720,10 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.4.15",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
- "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
@@ -2920,94 +3366,444 @@
"url": "https://opencollective.com/unts"
}
},
- "node_modules/@popperjs/core": {
- "version": "2.11.8",
- "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
- "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/popperjs"
- }
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@remirror/core-constants": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/plugin-babel": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
+ "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.10.4",
+ "@rollup/pluginutils": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "@types/babel__core": "^7.1.9",
+ "rollup": "^1.20.0||^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/babel__core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "11.2.1",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz",
+ "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==",
+ "dependencies": {
+ "@rollup/pluginutils": "^3.1.0",
+ "@types/resolve": "1.17.1",
+ "builtin-modules": "^3.1.0",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.19.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0"
+ }
+ },
+ "node_modules/@rollup/plugin-replace": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz",
+ "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==",
+ "dependencies": {
+ "@rollup/pluginutils": "^3.1.0",
+ "magic-string": "^0.25.7"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0 || ^2.0.0"
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
+ "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
+ "dependencies": {
+ "@types/estree": "0.0.39",
+ "estree-walker": "^1.0.1",
+ "picomatch": "^2.2.2"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0"
+ }
+ },
+ "node_modules/@rollup/pluginutils/node_modules/@types/estree": {
+ "version": "0.0.39",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
+ "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
},
- "node_modules/@remirror/core-constants": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
- "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
- "license": "MIT"
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
},
- "node_modules/@rollup/plugin-babel": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
- "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==",
- "dependencies": {
- "@babel/helper-module-imports": "^7.10.4",
- "@rollup/pluginutils": "^3.1.0"
- },
- "engines": {
- "node": ">= 10.0.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0",
- "@types/babel__core": "^7.1.9",
- "rollup": "^1.20.0||^2.0.0"
- },
- "peerDependenciesMeta": {
- "@types/babel__core": {
- "optional": true
- }
- }
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
},
- "node_modules/@rollup/plugin-node-resolve": {
- "version": "11.2.1",
- "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz",
- "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==",
- "dependencies": {
- "@rollup/pluginutils": "^3.1.0",
- "@types/resolve": "1.17.1",
- "builtin-modules": "^3.1.0",
- "deepmerge": "^4.2.2",
- "is-module": "^1.0.0",
- "resolve": "^1.19.0"
- },
- "engines": {
- "node": ">= 10.0.0"
- },
- "peerDependencies": {
- "rollup": "^1.20.0||^2.0.0"
- }
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
- "node_modules/@rollup/plugin-replace": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz",
- "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==",
- "dependencies": {
- "@rollup/pluginutils": "^3.1.0",
- "magic-string": "^0.25.7"
- },
- "peerDependencies": {
- "rollup": "^1.20.0 || ^2.0.0"
- }
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
- "node_modules/@rollup/pluginutils": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
- "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
- "dependencies": {
- "@types/estree": "0.0.39",
- "estree-walker": "^1.0.1",
- "picomatch": "^2.2.2"
- },
- "engines": {
- "node": ">= 8.0.0"
- },
- "peerDependencies": {
- "rollup": "^1.20.0||^2.0.0"
- }
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
- "node_modules/@rollup/pluginutils/node_modules/@types/estree": {
- "version": "0.0.39",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
- "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
"node_modules/@rushstack/eslint-patch": {
"version": "1.7.2",
@@ -3569,34 +4365,79 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
- "node_modules/@types/eslint": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
- "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@types/estree": "*",
- "@types/json-schema": "*"
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
}
},
- "node_modules/@types/eslint-scope": {
- "version": "3.7.7",
- "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
- "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
- "@types/eslint": "*",
- "@types/estree": "*"
+ "@types/d3-selection": "*"
}
},
- "node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
- "peer": true
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/glob": {
"version": "7.2.0",
@@ -3974,181 +4815,193 @@
"resolved": "https://registry.npmjs.org/@unly/utils/-/utils-1.0.3.tgz",
"integrity": "sha512-QTRknIDX56FvzGcIpBum5D/oRSlX3dkZ+l1op1jsFlYCTd925OGUb991V7zsFv3ePcqFfvfqfR5cNVv+w4JAOw=="
},
- "node_modules/@webassemblyjs/ast": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
- "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
+ "node_modules/@vitest/expect": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
+ "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@webassemblyjs/helper-numbers": "1.13.2",
- "@webassemblyjs/helper-wasm-bytecode": "1.13.2"
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@webassemblyjs/floating-point-hex-parser": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
- "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/@webassemblyjs/helper-api-error": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
- "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/@webassemblyjs/helper-buffer": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
- "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/@webassemblyjs/helper-numbers": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
- "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
+ "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@webassemblyjs/floating-point-hex-parser": "1.13.2",
- "@webassemblyjs/helper-api-error": "1.13.2",
- "@xtuc/long": "4.2.2"
+ "@vitest/spy": "3.2.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
}
},
- "node_modules/@webassemblyjs/helper-wasm-bytecode": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
- "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
"license": "MIT",
- "peer": true
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
},
- "node_modules/@webassemblyjs/helper-wasm-section": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
- "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@webassemblyjs/ast": "1.14.1",
- "@webassemblyjs/helper-buffer": "1.14.1",
- "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
- "@webassemblyjs/wasm-gen": "1.14.1"
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/@webassemblyjs/ieee754": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
- "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+ "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@xtuc/ieee754": "^1.2.0"
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@webassemblyjs/leb128": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
- "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
- "license": "Apache-2.0",
- "peer": true,
+ "node_modules/@vitest/runner": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
+ "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "@xtuc/long": "4.2.2"
+ "@vitest/utils": "3.2.7",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@webassemblyjs/utf8": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
- "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+ "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+ "dev": true,
"license": "MIT",
- "peer": true
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
},
- "node_modules/@webassemblyjs/wasm-edit": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
- "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
+ "node_modules/@vitest/snapshot/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@webassemblyjs/ast": "1.14.1",
- "@webassemblyjs/helper-buffer": "1.14.1",
- "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
- "@webassemblyjs/helper-wasm-section": "1.14.1",
- "@webassemblyjs/wasm-gen": "1.14.1",
- "@webassemblyjs/wasm-opt": "1.14.1",
- "@webassemblyjs/wasm-parser": "1.14.1",
- "@webassemblyjs/wast-printer": "1.14.1"
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/@webassemblyjs/wasm-gen": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
- "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
+ "node_modules/@vitest/spy": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
+ "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@webassemblyjs/ast": "1.14.1",
- "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
- "@webassemblyjs/ieee754": "1.13.2",
- "@webassemblyjs/leb128": "1.13.2",
- "@webassemblyjs/utf8": "1.13.2"
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@webassemblyjs/wasm-opt": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
- "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
+ "node_modules/@vitest/utils": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
+ "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@webassemblyjs/ast": "1.14.1",
- "@webassemblyjs/helper-buffer": "1.14.1",
- "@webassemblyjs/wasm-gen": "1.14.1",
- "@webassemblyjs/wasm-parser": "1.14.1"
+ "@vitest/pretty-format": "3.2.7",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@webassemblyjs/wasm-parser": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
- "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
+ "node_modules/@xyflow/react": {
+ "version": "12.11.2",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
+ "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
"license": "MIT",
- "peer": true,
"dependencies": {
- "@webassemblyjs/ast": "1.14.1",
- "@webassemblyjs/helper-api-error": "1.13.2",
- "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
- "@webassemblyjs/ieee754": "1.13.2",
- "@webassemblyjs/leb128": "1.13.2",
- "@webassemblyjs/utf8": "1.13.2"
+ "@xyflow/system": "0.0.79",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17",
+ "@types/react-dom": ">=17",
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
}
},
- "node_modules/@webassemblyjs/wast-printer": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
- "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
+ "node_modules/@xyflow/system": {
+ "version": "0.0.79",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
+ "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
"license": "MIT",
- "peer": true,
"dependencies": {
- "@webassemblyjs/ast": "1.14.1",
- "@xtuc/long": "4.2.2"
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
}
},
- "node_modules/@xtuc/ieee754": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
- "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
- "license": "BSD-3-Clause",
- "peer": true
- },
- "node_modules/@xtuc/long": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
- "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
- "license": "Apache-2.0",
- "peer": true
- },
"node_modules/@yr/monotone-cubic-spline": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
@@ -4184,24 +5037,11 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-import-phases": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
- "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=10.13.0"
+ "bin": {
+ "acorn": "bin/acorn"
},
- "peerDependencies": {
- "acorn": "^8.14.0"
+ "engines": {
+ "node": ">=0.4.0"
}
},
"node_modules/acorn-jsx": {
@@ -4546,6 +5386,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4848,6 +5698,16 @@
"node": ">=10.16.0"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -4951,6 +5811,23 @@
"node": ">=0.8"
}
},
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -4972,6 +5849,16 @@
"node": ">=0.8.0"
}
},
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -4996,15 +5883,11 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/chrome-trace-event": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
- "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=6.0"
- }
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
@@ -5215,6 +6098,111 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -5227,11 +6215,12 @@
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
},
"node_modules/debug": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
- "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
"dependencies": {
- "ms": "2.1.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -5242,6 +6231,16 @@
}
}
},
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5476,6 +6475,7 @@
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -5608,13 +6608,6 @@
"node": ">= 0.4"
}
},
- "node_modules/es-module-lexer": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
- "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
- "license": "MIT",
- "peer": true
- },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5665,6 +6658,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -6283,6 +7318,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -6294,6 +7330,7 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
"engines": {
"node": ">=4.0"
}
@@ -6311,16 +7348,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/events": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
- "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=0.8.x"
- }
- },
"node_modules/exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
@@ -6330,6 +7357,16 @@
"node": ">=0.8"
}
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -6391,6 +7428,24 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fflate": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz",
@@ -7808,20 +8863,6 @@
"uc.micro": "^2.0.0"
}
},
- "node_modules/loader-runner": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
- "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=6.11.5"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/webpack"
- }
- },
"node_modules/loader-utils": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
@@ -7903,6 +8944,13 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -8067,9 +9115,10 @@
}
},
"node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
},
"node_modules/mui-color-input": {
"version": "7.0.0",
@@ -8105,15 +9154,16 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -8127,13 +9177,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
- "node_modules/neo-async": {
- "version": "2.6.2",
- "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
- "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
- "license": "MIT",
- "peer": true
- },
"node_modules/next": {
"version": "14.0.4",
"resolved": "https://registry.npmjs.org/next/-/next-14.0.4.tgz",
@@ -8601,6 +9644,23 @@
"node": ">=8"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8723,9 +9783,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.35",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
- "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
+ "version": "8.5.19",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"dev": true,
"funding": [
{
@@ -8741,10 +9801,11 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "nanoid": "^3.3.7",
- "picocolors": "^1.0.0",
- "source-map-js": "^1.0.2"
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -9955,6 +11016,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -9990,9 +11058,10 @@
}
},
"node_modules/source-map-js": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
- "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -10032,6 +11101,20 @@
"node": ">=0.8"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -10227,6 +11310,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/styled-components": {
"version": "6.1.8",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.8.tgz",
@@ -10572,6 +11675,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -10732,25 +11836,99 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
- "node_modules/thenify": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
- "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
- "dependencies": {
- "any-promise": "^1.0.0"
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
}
},
- "node_modules/thenify-all": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
- "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
- "dependencies": {
- "thenify": ">= 3.1.0 < 4"
- },
+ "license": "MIT",
"engines": {
- "node": ">=0.8"
+ "node": ">=14.0.0"
}
},
"node_modules/tippy.js": {
@@ -10929,7 +12107,7 @@
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
- "devOptional": true,
+ "dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11100,191 +12278,291 @@
"uuid": "dist/bin/uuid"
}
},
- "node_modules/void-elements": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
- "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/w3c-keyname": {
- "version": "2.2.8",
- "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
- "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
- "license": "MIT"
- },
- "node_modules/warning": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
- "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
- "dependencies": {
- "loose-envify": "^1.0.0"
- }
- },
- "node_modules/watchpack": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
- "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "glob-to-regexp": "^0.4.1",
- "graceful-fs": "^4.1.2"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/webidl-conversions": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
- "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
- },
- "node_modules/webpack": {
- "version": "5.105.4",
- "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz",
- "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@types/eslint-scope": "^3.7.7",
- "@types/estree": "^1.0.8",
- "@types/json-schema": "^7.0.15",
- "@webassemblyjs/ast": "^1.14.1",
- "@webassemblyjs/wasm-edit": "^1.14.1",
- "@webassemblyjs/wasm-parser": "^1.14.1",
- "acorn": "^8.16.0",
- "acorn-import-phases": "^1.0.3",
- "browserslist": "^4.28.1",
- "chrome-trace-event": "^1.0.2",
- "enhanced-resolve": "^5.20.0",
- "es-module-lexer": "^2.0.0",
- "eslint-scope": "5.1.1",
- "events": "^3.2.0",
- "glob-to-regexp": "^0.4.1",
- "graceful-fs": "^4.2.11",
- "json-parse-even-better-errors": "^2.3.1",
- "loader-runner": "^4.3.1",
- "mime-types": "^2.1.27",
- "neo-async": "^2.6.2",
- "schema-utils": "^4.3.3",
- "tapable": "^2.3.0",
- "terser-webpack-plugin": "^5.3.17",
- "watchpack": "^2.5.1",
- "webpack-sources": "^3.3.4"
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
},
"bin": {
- "webpack": "bin/webpack.js"
+ "vite": "bin/vite.js"
},
"engines": {
- "node": ">=10.13.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/webpack"
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
- "webpack-cli": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
"optional": true
}
}
},
- "node_modules/webpack-sources": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz",
- "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==",
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
"license": "MIT",
- "peer": true,
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
"engines": {
- "node": ">=10.13.0"
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/webpack/node_modules/ajv": {
- "version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
- "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "node_modules/vite-node/node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
"license": "MIT",
- "peer": true,
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^3.0.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2"
+ "engines": {
+ "node": ">=12"
},
"funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
+ "url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/webpack/node_modules/ajv-keywords": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
- "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+ "node_modules/vite/node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "fast-deep-equal": "^3.1.3"
+ "@types/estree": "1.0.9"
},
- "peerDependencies": {
- "ajv": "^8.8.2"
- }
- },
- "node_modules/webpack/node_modules/eslint-scope": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
- "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
- "license": "BSD-2-Clause",
- "peer": true,
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^4.1.1"
+ "bin": {
+ "rollup": "dist/bin/rollup"
},
"engines": {
- "node": ">=8.0.0"
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
}
},
- "node_modules/webpack/node_modules/estraverse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
- "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
- "license": "BSD-2-Clause",
- "peer": true,
+ "node_modules/vitest": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
+ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.7",
+ "@vitest/mocker": "3.2.7",
+ "@vitest/pretty-format": "^3.2.7",
+ "@vitest/runner": "3.2.7",
+ "@vitest/snapshot": "3.2.7",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
"engines": {
- "node": ">=4.0"
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.7",
+ "@vitest/ui": "3.2.7",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
}
},
- "node_modules/webpack/node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
"license": "MIT",
- "peer": true
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
},
- "node_modules/webpack/node_modules/schema-utils": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
- "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
"license": "MIT",
- "peer": true,
- "dependencies": {
- "@types/json-schema": "^7.0.9",
- "ajv": "^8.9.0",
- "ajv-formats": "^2.1.1",
- "ajv-keywords": "^5.1.0"
- },
"engines": {
- "node": ">= 10.13.0"
+ "node": ">=12"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/webpack"
+ "url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/webpack/node_modules/watchpack": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
- "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
- "license": "MIT",
- "peer": true,
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
+ "node_modules/watchpack": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
+ "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
"dependencies": {
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2"
@@ -11293,6 +12571,11 @@
"node": ">=10.13.0"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
+ },
"node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
@@ -11392,6 +12675,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
@@ -11909,6 +13209,43 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index ccd8e66..3a630eb 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,9 @@
"build": "next build",
"start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start",
"lint": "next lint",
- "type-check": "tsc --noEmit"
+ "type-check": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@emotion/cache": "^11.11.0",
@@ -40,6 +42,7 @@
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@unly/universal-language-detector": "^2.0.3",
+ "@xyflow/react": "^12.11.2",
"apexcharts": "^3.45.2",
"axios": "^1.9.0",
"dayjs": "^1.11.10",
@@ -67,7 +70,8 @@
"styled-components": "^6.1.8",
"sweetalert2": "^11.10.3",
"xlsx": "^0.18.5",
- "xlsx-js-style": "^1.2.0"
+ "xlsx-js-style": "^1.2.0",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@types/lodash": "^4.14.202",
@@ -85,6 +89,7 @@
"postcss": "^8.4.33",
"prettier": "3.1.1",
"tailwindcss": "^3.4.1",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^3.2.4"
}
}
diff --git a/src/app/(main)/itemTracing/page.tsx b/src/app/(main)/itemTracing/page.tsx
new file mode 100644
index 0000000..4c3c8e4
--- /dev/null
+++ b/src/app/(main)/itemTracing/page.tsx
@@ -0,0 +1,44 @@
+import { I18nProvider, getServerI18n } from "@/i18n";
+import ItemTracing from "@/components/ItemTracing";
+import { AUTH } from "@/authorities";
+import { authOptions } from "@/config/authConfig";
+import { Stack, Typography } from "@mui/material";
+import { Metadata } from "next";
+import { getServerSession } from "next-auth";
+import { redirect } from "next/navigation";
+import { Suspense } from "react";
+import ItemTracingLoading from "@/components/ItemTracing/ItemTracingLoading";
+
+export const metadata: Metadata = {
+ title: "Item Tracing",
+};
+
+const canAccess = (abilities: string[]) =>
+ abilities.includes(AUTH.ITEM_TRACING);
+
+const ItemTracingPage: React.FC = async () => {
+ const session = await getServerSession(authOptions);
+ const abilities = session?.user?.abilities ?? [];
+ if (!canAccess(abilities)) {
+ redirect("/dashboard");
+ }
+
+ const { t } = await getServerI18n("itemTracing");
+
+ return (
+ <>
+
+
+ {t("title")}
+
+
+
+ }>
+
+
+
+ >
+ );
+};
+
+export default ItemTracingPage;
diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx
index 83bb62b..266020a 100644
--- a/src/app/(main)/report/page.tsx
+++ b/src/app/(main)/report/page.tsx
@@ -31,6 +31,7 @@ import {
} from './semiFGProductionAnalysisApi';
import { generateGrnReportExcel } from './grnReportApi';
import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi';
+import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi';
import {
FEATURE_USAGE,
FEATURE_USAGE_ACTION,
@@ -42,6 +43,7 @@ interface ItemCodeWithName {
name: string;
}
+/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */
export default function ReportPage() {
const { data: session } = useSession() as { data: SessionWithTokens | null };
const includeGrnFinancialColumns =
@@ -267,12 +269,24 @@ export default function ReportPage() {
);
} else if (currentReport.id === 'rep-015') {
await generateBomShopSyncReportExcel(criteria, currentReport.title);
+ } else if (currentReport.id === 'rep-017') {
+ await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title);
} else {
// Backend returns actual .xlsx bytes for this Excel endpoint.
- const queryParams =
+ let queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: new URLSearchParams(criteria).toString();
+ // rep-016: single-day UI — mirror dateStart to dateEnd for backend API.
+ if (currentReport.id === 'rep-016') {
+ const p = new URLSearchParams(criteria);
+ const day = (criteria.dateStart || '').trim();
+ if (day) {
+ p.set('dateStart', day);
+ p.set('dateEnd', day);
+ }
+ queryParams = p.toString();
+ }
const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`;
const response = await clientAuthFetch(excelUrl, {
diff --git a/src/app/(main)/report/reportCategories.ts b/src/app/(main)/report/reportCategories.ts
index 7c6cced..1c86308 100644
--- a/src/app/(main)/report/reportCategories.ts
+++ b/src/app/(main)/report/reportCategories.ts
@@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [
headerBg: "#b3d4f0",
bodyBg: "#eef5fc",
accent: "#1565c0",
- reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013"],
+ reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017"],
},
{
id: "production",
diff --git a/src/app/(main)/report/shopOrderReplenishmentReportApi.ts b/src/app/(main)/report/shopOrderReplenishmentReportApi.ts
new file mode 100644
index 0000000..bca78f7
--- /dev/null
+++ b/src/app/(main)/report/shopOrderReplenishmentReportApi.ts
@@ -0,0 +1,161 @@
+"use client";
+
+import { NEXT_PUBLIC_API_URL } from "@/config/api";
+import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
+import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx";
+
+export interface ShopOrderReplenishmentReportRow {
+ shopNo?: string;
+ shopName?: string;
+ shopOrderDate?: string;
+ shopOrderNo?: string;
+ itemNo?: string;
+ itemName?: string;
+ firstOrderQty?: number | string;
+ firstOrderActualPickQty?: number | string;
+ reorderQty?: number | string;
+ reorderDate?: string;
+ reason?: string;
+ actualDeliveredQty?: number | string;
+ deliveredDate?: string;
+ [key: string]: unknown;
+}
+
+export interface ShopOrderReplenishmentReportResponse {
+ rows: ShopOrderReplenishmentReportRow[];
+}
+
+const SHEET_NAME = "店鋪訂單補貨記錄";
+
+const NO_DATA_NOTE =
+ "(篩選範圍內無資料 / No records in the selected range)";
+
+function emptySheetRow(note: string = NO_DATA_NOTE): Record {
+ return {
+ "Shop No. / 店鋪編號": note,
+ "Shop Name / 店鋪名稱": "",
+ "Shop Order Date / 店鋪訂單日期": "",
+ "Shop Order No. / 店鋪訂單編號": "",
+ "Item No. / 貨品編號": "",
+ "Item Name / 貨品名稱": "",
+ "First Order Qty / 原訂單數量": "",
+ "First Order Actual Pick Qty / 原單實際提料數量": "",
+ "Reorder Qty / 補貨數量": "",
+ "Reorder Date / 補貨日期": "",
+ "Reason / 補貨原因": "",
+ "Actual Delivered Qty / 實際補貨數量": "",
+ "Delivered Date / 送貨日期": "",
+ };
+}
+
+function formatReason(reason: string | undefined): string {
+ switch ((reason ?? "").trim()) {
+ case "quality_issue":
+ return "質素問題";
+ case "out_of_stock":
+ return "缺貨";
+ case "other":
+ return "其他";
+ default:
+ return reason ?? "";
+ }
+}
+
+function formatDateCell(value: unknown): string {
+ if (value == null || value === "") return "";
+ if (typeof value === "number" && Number.isFinite(value)) {
+ // Jackson may serialize java.sql.Date as epoch millis or seconds
+ const ms = value > 1e12 ? value : value * 1000;
+ const d = new Date(ms);
+ if (Number.isNaN(d.getTime())) return String(value);
+ return d.toISOString().slice(0, 10);
+ }
+ const s = String(value).trim();
+ if (/^\d{10,13}$/.test(s)) {
+ const n = Number(s);
+ const ms = s.length >= 13 ? n : n * 1000;
+ const d = new Date(ms);
+ if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
+ }
+ // JDBC / string date: "2026-08-01" or "2026-08-01 00:00:00.0"
+ return s.length >= 10 ? s.slice(0, 10) : s;
+}
+
+function formatQty(value: unknown): string | number {
+ if (value === null || value === undefined || value === "") return "";
+ const n = typeof value === "number" ? value : Number(value);
+ if (!Number.isFinite(n)) return String(value);
+ return n;
+}
+
+function toExcelRow(r: ShopOrderReplenishmentReportRow): Record {
+ const base = emptySheetRow("");
+ return {
+ ...base,
+ "Shop No. / 店鋪編號": r.shopNo ?? "",
+ "Shop Name / 店鋪名稱": r.shopName ?? "",
+ "Shop Order Date / 店鋪訂單日期": formatDateCell(r.shopOrderDate),
+ "Shop Order No. / 店鋪訂單編號": r.shopOrderNo ?? "",
+ "Item No. / 貨品編號": r.itemNo ?? "",
+ "Item Name / 貨品名稱": r.itemName ?? "",
+ "First Order Qty / 原訂單數量": formatQty(r.firstOrderQty),
+ "First Order Actual Pick Qty / 原單實際提料數量": formatQty(r.firstOrderActualPickQty),
+ "Reorder Qty / 補貨數量": formatQty(r.reorderQty),
+ "Reorder Date / 補貨日期": formatDateCell(r.reorderDate),
+ "Reason / 補貨原因": formatReason(r.reason),
+ "Actual Delivered Qty / 實際補貨數量": formatQty(r.actualDeliveredQty),
+ "Delivered Date / 送貨日期": formatDateCell(r.deliveredDate),
+ };
+}
+
+export async function fetchShopOrderReplenishmentReportData(
+ criteria: Record,
+): Promise {
+ const queryParams = new URLSearchParams(criteria).toString();
+ const url = `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment?${queryParams}`;
+
+ const response = await clientAuthFetch(url, {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ });
+
+ if (response.status === 401 || response.status === 403)
+ throw new Error("Unauthorized");
+ if (!response.ok)
+ throw new Error(`HTTP error! status: ${response.status}`);
+
+ const data = (await response.json()) as
+ | ShopOrderReplenishmentReportResponse
+ | ShopOrderReplenishmentReportRow[];
+ if (Array.isArray(data)) return data;
+ return data.rows ?? [];
+}
+
+/**
+ * Generate and download Shop Orders Replenishment Records as Excel.
+ */
+export async function generateShopOrderReplenishmentReportExcel(
+ criteria: Record,
+ reportTitle: string = "店鋪訂單補貨記錄",
+): Promise {
+ const rows = await fetchShopOrderReplenishmentReportData(criteria);
+ const excelRows =
+ rows.length > 0 ? rows.map(toExcelRow) : [emptySheetRow()];
+
+ const dateCandidates = [
+ criteria.reorderDateStart,
+ criteria.reorderDateEnd,
+ criteria.shopOrderDateStart,
+ criteria.shopOrderDateEnd,
+ criteria.deliveredDateStart,
+ criteria.deliveredDateEnd,
+ ].filter((v) => (v ?? "").trim().length > 0);
+ const datePart =
+ dateCandidates.length > 0
+ ? dateCandidates.join("_")
+ : new Date().toISOString().slice(0, 10);
+ const safeDatePart = datePart.replace(/[^\d\-_/]/g, "");
+ const filename = `${reportTitle}_${safeDatePart}`;
+
+ exportChartToXlsx(excelRows, filename, SHEET_NAME);
+}
diff --git a/src/app/(main)/settings/itemPrice/page.tsx b/src/app/(main)/settings/itemPrice/page.tsx
index 9b386b3..72bae26 100644
--- a/src/app/(main)/settings/itemPrice/page.tsx
+++ b/src/app/(main)/settings/itemPrice/page.tsx
@@ -15,7 +15,7 @@ const ItemPriceSetting: React.FC = async () => {
<>
-
+
}>
diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts
index b9353cd..65af3d6 100644
--- a/src/app/api/doworkbench/actions.ts
+++ b/src/app/api/doworkbench/actions.ts
@@ -94,6 +94,8 @@ export type WorkbenchScanPickBody = {
excludeWarehouseCodes?: string[] | null;
/** Optional decimal string or number serialized by JSON */
qty?: number | string | null;
+ /** Just Complete button (no QR) — DO user-audit flag */
+ justComplete?: boolean | null;
userId: number;
};
@@ -144,6 +146,7 @@ export async function workbenchScanPick(
...(storeId !== undefined ? { storeId } : {}),
...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}),
...(qty !== undefined ? { qty } : {}),
+ ...(body.justComplete === true ? { justComplete: true } : {}),
userId: body.userId,
}),
headers: { "Content-Type": "application/json" },
@@ -242,13 +245,16 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelection(
shopName?: string,
storeId?: string,
truck?: string,
- releaseType?: string
+ releaseType?: string,
+ /** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */
+ floor?: string
): Promise {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
if (storeId?.trim()) params.append("storeId", storeId.trim());
if (truck?.trim()) params.append("truck", truck.trim());
if (releaseType?.trim()) params.append("releaseType", releaseType.trim());
+ if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`;
const response = await serverFetchJson(url, { method: "GET" });
@@ -261,7 +267,9 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
storeId?: string,
truck?: string,
requiredDeliveryDate?: string,
- releaseType?: string
+ releaseType?: string,
+ /** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */
+ floor?: string
): Promise {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
@@ -269,6 +277,7 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
if (truck?.trim()) params.append("truck", truck.trim());
if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim());
if (releaseType?.trim()) params.append("releaseType", releaseType.trim());
+ if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`;
const response = await serverFetchJson(url, { method: "GET" });
@@ -417,6 +426,8 @@ export async function fetchWorkbenchCompletedLotDetails(
export type WorkbenchScanPayload = {
itemId: number;
stockInLineId: number;
+ /** POL UomConversion id — when set, sameItemLots only include matching lot UOM */
+ uomId?: number | null;
};
export async function fetchWorkbenchPrinters() {
return serverFetchJson(`${BASE_API_URL}/printers`, {
@@ -433,9 +444,16 @@ export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) {
});
}
-export async function fetchWorkbenchAvailableLotsByItem(itemId: number) {
+export async function fetchWorkbenchAvailableLotsByItem(
+ itemId: number,
+ uomId?: number | null,
+) {
+ const qs =
+ uomId != null && Number.isFinite(Number(uomId)) && Number(uomId) > 0
+ ? `?uomId=${Number(uomId)}`
+ : "";
return serverFetchJson(
- `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`,
+ `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}${qs}`,
{
method: "GET",
cache: "no-store",
diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts
index 0f10e48..5da0eeb 100644
--- a/src/app/api/inventory/actions.ts
+++ b/src/app/api/inventory/actions.ts
@@ -23,6 +23,7 @@ export interface LotLineInfo {
lotNo: string;
remainingQty: number;
uom: string;
+ uomId: number;
}
export interface SearchInventoryLotLine extends Pageable {
@@ -160,13 +161,32 @@ async function fetchInventoriesImpl(data: SearchInventory) {
);
}
+async function fetchInventoriesLatestImpl(data: SearchInventory) {
+ const queryStr = convertObjToURLSearchParams(data);
+ return serverFetchJson(
+ `${BASE_API_URL}/inventory/searchLatest/getRecordByPage?${queryStr}`,
+ { next: { tags: ["inventories"] } },
+ );
+}
+
export const fetchInventories = cache(fetchInventoriesImpl);
+/**
+ * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17
+ * Inventory search page: latest inventory row per item (no baseUnit/uomId filter).
+ */
+export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl);
+
/** Bypass React cache() after mutations so lists show fresh qty. */
export async function fetchInventoriesFresh(data: SearchInventory) {
return fetchInventoriesImpl(data);
}
+/** Bypass React cache() for inventory search page latest-inventory search. */
+export async function fetchInventoriesLatestFresh(data: SearchInventory) {
+ return fetchInventoriesLatestImpl(data);
+}
+
async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) {
const queryStr = convertObjToURLSearchParams(data);
return serverFetchJson(
diff --git a/src/app/api/itemTracing/actions.ts b/src/app/api/itemTracing/actions.ts
new file mode 100644
index 0000000..91be59c
--- /dev/null
+++ b/src/app/api/itemTracing/actions.ts
@@ -0,0 +1,24 @@
+"use server";
+
+import { BASE_API_URL } from "@/config/api";
+import { convertObjToURLSearchParams } from "@/app/utils/commonUtil";
+import { serverFetchJson } from "@/app/utils/fetchUtil";
+import { ItemLotTraceParams, ItemLotTraceResponse } from ".";
+import { parseItemLotTraceResponse } from "./schema";
+
+export const fetchItemLotTrace = async (
+ params: ItemLotTraceParams,
+): Promise => {
+ const query = convertObjToURLSearchParams(params as Record);
+ const json = await serverFetchJson(
+ `${BASE_API_URL}/inventoryLotLine/trace?${query}`,
+ { method: "GET" },
+ );
+ return parseItemLotTraceResponse(json) as unknown as ItemLotTraceResponse;
+};
+
+/**
+ * Backend also exposes GET /inventoryLotLine/trace/location/{inventoryLotId} for
+ * lazy location blocks. The main /trace payload already includes locationBlocks,
+ * so the UI uses that eager path and does not call a separate location fetch.
+ */
diff --git a/src/app/api/itemTracing/index.ts b/src/app/api/itemTracing/index.ts
new file mode 100644
index 0000000..fedea25
--- /dev/null
+++ b/src/app/api/itemTracing/index.ts
@@ -0,0 +1,496 @@
+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;
+ doOutboundQtyChanged?: 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 | null;
+ 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;
+ /** True when pick_order_line.qty ≠ SUM(stock_out_line.qty) for that line (改數). */
+ qtyChanged?: boolean;
+}
+
+export interface ItemLotTraceReturnEvent {
+ stockOutLineId: number;
+ qty: number;
+ refCode: string;
+ movementType: string;
+ warehouseCode: string;
+ handler: string;
+ timestamp: string | null;
+ remarks: string;
+}
+
+export interface ItemLotTraceLotRelation {
+ relationType: string;
+ inventoryLotId: number | null;
+ lotNo: string;
+ itemCode: string;
+ itemName: string;
+ qty: number;
+ productLotNo: string;
+ timestamp: string | null;
+}
+
+export interface ItemLotTraceGraphEdge {
+ fromKey: string;
+ toKey: string;
+ kind?: string;
+}
+
+export interface ItemLotTraceGraphNodeRef {
+ key: string;
+ kind: string;
+ scopeKey?: string | null;
+ refCode?: string | null;
+ traceLotNo?: string | null;
+ traceItemCode?: string | null;
+ /** Warehouse code for multi-location visual differentiation. */
+ warehouseCode?: string | null;
+}
+
+export interface ItemLotTraceGraph {
+ nodes: ItemLotTraceGraphNodeRef[];
+ edges: ItemLotTraceGraphEdge[];
+}
+
+export interface ItemLotTraceResponse {
+ lot: ItemLotTraceLotInfo;
+ warehouseLines: ItemLotTraceWarehouseLine[];
+ origins: ItemLotTraceOrigin[];
+ purchaseEvents?: ItemLotTracePurchaseEvent[];
+ qcResults: ItemLotTraceQcResult[];
+ putawayEvents: ItemLotTracePutawayEvent[];
+ movements: ItemLotTraceMovement[];
+ outboundUsage: ItemLotTraceOutboundUsage[];
+ stockTakeEvents: ItemLotTraceStockTakeEvent[];
+ adjustments: ItemLotTraceAdjustment[];
+ transfers: ItemLotTraceTransfer[];
+ bomTrace: ItemLotTraceBomTrace;
+ joPrelude: ItemLotTraceJoPrelude | null;
+ productionSteps: ItemLotTraceProductionStep[];
+ byproductLots: ItemLotTraceByproductLot[];
+ openMovements: ItemLotTraceOpenMovement[];
+ failEvents: ItemLotTraceFailEvent[];
+ doDeliveries: ItemLotTraceDoDelivery[];
+ replenishmentEvents?: ItemLotTraceReplenishmentEvent[];
+ returnEvents: ItemLotTraceReturnEvent[];
+ lotRelations: ItemLotTraceLotRelation[];
+ alternateLocations: ItemLotTraceAlternateLocation[];
+ /** Per-location full trace blocks for alternate inventory_lot rows sharing same lotNo+itemId.
+ * Empty when there is only one location for this lot. */
+ locationBlocks?: ItemLotTraceLocationBlock[];
+ traceGraph?: ItemLotTraceGraph | null;
+}
+
+export interface ItemLotTraceParams {
+ stockInLineId?: number;
+ inventoryLotLineId?: number;
+ lotNo?: string;
+ itemId?: number;
+ itemCode?: string;
+}
diff --git a/src/app/api/itemTracing/schema.ts b/src/app/api/itemTracing/schema.ts
new file mode 100644
index 0000000..edf0774
--- /dev/null
+++ b/src/app/api/itemTracing/schema.ts
@@ -0,0 +1,211 @@
+import { z } from "zod";
+
+const nullableStr = z.string().nullable().optional();
+const nullableNum = z.number().nullable().optional();
+
+const ItemLotTraceOriginSchema = z
+ .object({
+ stockInLineId: z.number(),
+ type: z.string(),
+ refCode: z.string(),
+ refId: nullableNum,
+ supplierCode: z.string(),
+ supplierName: z.string(),
+ dnNo: z.string(),
+ receiptDate: nullableStr,
+ acceptedQty: z.number(),
+ status: z.string(),
+ })
+ .passthrough();
+
+const ItemLotTraceQcResultSchema = z
+ .object({
+ stockInLineId: z.number(),
+ qcPassed: z.boolean(),
+ failQty: z.number(),
+ acceptedQty: z.number(),
+ remarks: z.string(),
+ handledBy: z.string(),
+ created: nullableStr,
+ })
+ .passthrough();
+
+const ItemLotTracePutawayEventSchema = z
+ .object({
+ inventoryLotLineId: z.number(),
+ stockInLineId: nullableNum,
+ warehouseCode: z.string(),
+ qty: z.number(),
+ handledBy: z.string(),
+ timestamp: nullableStr,
+ refType: z.string(),
+ refCode: z.string(),
+ refId: nullableNum,
+ status: z.string().optional(),
+ })
+ .passthrough();
+
+const ItemLotTraceProductionStepSchema = z
+ .object({
+ processLineId: z.number(),
+ stepName: z.string(),
+ description: z.string(),
+ equipmentCode: z.string(),
+ equipmentName: z.string(),
+ operatorName: z.string(),
+ startTime: nullableStr,
+ endTime: nullableStr,
+ outputQty: z.number(),
+ scrapQty: z.number(),
+ defectQty: z.number(),
+ status: z.string(),
+ seqNo: nullableNum,
+ bomProcessId: nullableNum,
+ })
+ .passthrough();
+
+const ItemLotTraceMaterialInputSchema: z.ZodType> = z.lazy(() =>
+ z
+ .object({
+ jobOrderCode: z.string(),
+ jobOrderId: nullableNum,
+ materialItemCode: z.string(),
+ materialItemName: z.string(),
+ materialLotNo: z.string(),
+ materialStockInLineId: nullableNum,
+ materialInventoryLotId: nullableNum,
+ materialQty: z.number(),
+ materialUom: z.string().optional(),
+ bomQtyPerUnit: nullableNum,
+ bomProcessId: nullableNum,
+ bomProcessSeqNo: nullableNum,
+ assignedStepName: z.string().optional(),
+ pickOrderCode: z.string(),
+ pickOrderId: nullableNum,
+ consoCode: z.string(),
+ pickedAt: nullableStr,
+ stockInOrigin: ItemLotTraceOriginSchema.nullable().optional(),
+ purchaseEvents: z.array(z.record(z.unknown())).optional(),
+ qcResults: z.array(ItemLotTraceQcResultSchema),
+ putawayEvents: z.array(ItemLotTracePutawayEventSchema).optional(),
+ nestedJoPrelude: ItemLotTraceJoPreludeSchema.nullable().optional(),
+ productionSteps: z.array(ItemLotTraceProductionStepSchema).optional(),
+ })
+ .passthrough(),
+);
+
+const ItemLotTraceJoPreludeSchema: z.ZodType> = z.lazy(() =>
+ z
+ .object({
+ jobOrder: z
+ .object({
+ jobOrderId: z.number(),
+ jobOrderCode: z.string(),
+ planStart: nullableStr,
+ createdAt: nullableStr.optional(),
+ reqQty: z.number(),
+ status: z.string(),
+ })
+ .passthrough(),
+ pickOrders: z.array(z.record(z.unknown())),
+ materialInputs: z.array(ItemLotTraceMaterialInputSchema),
+ })
+ .passthrough(),
+);
+
+const ItemLotTraceGraphEdgeSchema = z
+ .object({
+ fromKey: z.string(),
+ toKey: z.string(),
+ kind: z.string(),
+ })
+ .passthrough();
+
+const ItemLotTraceGraphNodeRefSchema = z
+ .object({
+ key: z.string(),
+ kind: z.string(),
+ scopeKey: nullableStr,
+ refCode: nullableStr,
+ traceLotNo: nullableStr,
+ traceItemCode: nullableStr,
+ warehouseCode: nullableStr,
+ })
+ .passthrough();
+
+const ItemLotTraceGraphSchema = z
+ .object({
+ nodes: z.array(ItemLotTraceGraphNodeRefSchema),
+ edges: z.array(ItemLotTraceGraphEdgeSchema),
+ })
+ .passthrough()
+ .nullable()
+ .optional();
+
+export const ItemLotTraceResponseSchema = z
+ .object({
+ lot: z
+ .object({
+ inventoryLotId: z.number(),
+ lotNo: z.string(),
+ itemId: z.number(),
+ itemCode: z.string(),
+ itemName: z.string(),
+ expiryDate: nullableStr,
+ productionDate: nullableStr,
+ stockInDate: nullableStr,
+ uom: z.string(),
+ primaryStockInLineId: nullableNum,
+ })
+ .passthrough(),
+ warehouseLines: z.array(z.record(z.unknown())),
+ origins: z.array(ItemLotTraceOriginSchema),
+ purchaseEvents: z.array(z.record(z.unknown())).optional(),
+ qcResults: z.array(ItemLotTraceQcResultSchema),
+ putawayEvents: z.array(ItemLotTracePutawayEventSchema),
+ movements: z.array(z.record(z.unknown())),
+ outboundUsage: z.array(z.record(z.unknown())),
+ stockTakeEvents: z.array(z.record(z.unknown())),
+ adjustments: z.array(z.record(z.unknown())),
+ transfers: z.array(z.record(z.unknown())),
+ bomTrace: z.record(z.unknown()),
+ joPrelude: ItemLotTraceJoPreludeSchema.nullable(),
+ productionSteps: z.array(ItemLotTraceProductionStepSchema),
+ byproductLots: z.array(z.record(z.unknown())),
+ openMovements: z.array(z.record(z.unknown())),
+ failEvents: z.array(z.record(z.unknown())),
+ doDeliveries: z.array(z.record(z.unknown())),
+ replenishmentEvents: z.array(z.record(z.unknown())).optional(),
+ returnEvents: z.array(z.record(z.unknown())),
+ lotRelations: z.array(z.record(z.unknown())),
+ alternateLocations: z.array(z.record(z.unknown())),
+ locationBlocks: z.array(z.record(z.unknown())).optional(),
+ traceGraph: ItemLotTraceGraphSchema,
+ })
+ .passthrough();
+
+export type ItemLotTraceResponseParsed = z.infer;
+
+export const parseItemLotTraceResponse = (data: unknown): ItemLotTraceResponseParsed =>
+ ItemLotTraceResponseSchema.parse(data);
+
+/** Collect dot-path keys for contract snapshot tests. */
+export const collectObjectPaths = (
+ obj: unknown,
+ prefix = "",
+ depth = 0,
+ maxDepth = 6,
+): string[] => {
+ if (obj == null || depth > maxDepth) return prefix ? [prefix] : [];
+ if (Array.isArray(obj)) {
+ if (obj.length === 0) return prefix ? [prefix] : [];
+ return collectObjectPaths(obj[0], prefix ? `${prefix}[]` : "[]", depth + 1, maxDepth);
+ }
+ if (typeof obj !== "object") return prefix ? [prefix] : [];
+ const paths: string[] = prefix ? [prefix] : [];
+ Object.keys(obj as Record).sort().forEach((key) => {
+ const next = prefix ? `${prefix}.${key}` : key;
+ paths.push(...collectObjectPaths((obj as Record)[key], next, depth + 1, maxDepth));
+ });
+ return paths;
+};
diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts
index c4d4bc7..1a22f24 100644
--- a/src/app/api/jo/actions.ts
+++ b/src/app/api/jo/actions.ts
@@ -368,6 +368,8 @@ export interface AllJoborderProductProcessInfoResponse {
itemCode: string;
itemName: string;
bomDescription?: string | null;
+ /** BOM.type (e.g. drink / Powder_Mixture / other). */
+ bomType?: string | null;
lotNo: string;
requiredQty: number;
jobOrderId: number;
@@ -381,6 +383,26 @@ export interface AllJoborderProductProcessInfoResponse {
productProcessLineCount: number;
FinishedProductProcessLineCount: number;
lines: ProductProcessInfoResponse[];
+ isPicked?: boolean | null;
+ /** Fine-grained pick/process bucket from backend. */
+ pickProcessBucket?: ProductionProcessFinePickBucket | null;
+}
+
+/** Fine-grained buckets returned per row. */
+export type ProductionProcessFinePickBucket =
+ | "not_picked_not_started"
+ | "picked_not_started"
+ | "picked_started"
+ | "not_picked_started";
+
+/** Merged tab filter: pending = not started; processing = started. */
+export type ProductionProcessPickBucket = "pending" | "processing" | ProductionProcessFinePickBucket;
+
+export interface JobOrderProductProcessBucketCounts {
+ notPickedNotStarted: number;
+ pickedNotStarted: number;
+ pickedStarted: number;
+ notPickedStarted: number;
}
export interface JobOrderProductProcessPageResponse {
@@ -388,6 +410,9 @@ export interface JobOrderProductProcessPageResponse {
totalJobOrders: number;
page: number;
size: number;
+ bucketCounts?: JobOrderProductProcessBucketCounts | null;
+ searchDate?: string | null;
+ carriedOverCount?: number | null;
}
export interface ProductProcessInfoResponse {
id: number;
@@ -558,7 +583,10 @@ export interface AllJoPickOrderResponse {
jobOrderType: string | null;
itemId: number;
itemName: string;
+ itemCode?: string | null;
bomDescription?: string | null;
+ /** BOM.type (e.g. drink / Powder_Mixture / other). */
+ bomType?: string | null;
lotNo: string | null;
planStart?: string | number[] | null;
reqQty: number;
@@ -877,8 +905,9 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n
);
});
+/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
export const fetchJoborderProductProcessesPage = cache(async (params: {
- /** Job order planStart 區間起(YYYY-MM-DD,含當日) */
+ /** Job order / process date(YYYY-MM-DD) */
date?: string | null;
itemCode?: string | null;
jobOrderCode?: string | null;
@@ -888,6 +917,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
includePutaway?: boolean | null;
/** all | completed | notCompleted */
putawayStatus?: string | null;
+ /** Production list carry-over window (days before date). */
+ lookbackDays?: number | null;
+ /** Pick/process tab filter when lookbackDays is set. */
+ bucket?: ProductionProcessPickBucket | "all" | null;
page?: number;
size?: number;
}) => {
@@ -900,6 +933,8 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
includePutaway,
putawayStatus,
type,
+ lookbackDays,
+ bucket,
page = 0,
size = 50,
} = params;
@@ -917,6 +952,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
queryParts.push(`includePutaway=${includePutaway}`);
}
if (putawayStatus) queryParts.push(`putawayStatus=${encodeURIComponent(putawayStatus)}`);
+ if (lookbackDays !== undefined && lookbackDays !== null) {
+ queryParts.push(`lookbackDays=${lookbackDays}`);
+ }
+ if (bucket) queryParts.push(`bucket=${encodeURIComponent(bucket)}`);
queryParts.push(`page=${page}`);
queryParts.push(`size=${size}`);
@@ -1203,8 +1242,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());
@@ -1636,6 +1682,37 @@ export const fetchOperatorKpi = cache(async (date?: string) => {
});
});
+// ===== Drink Production Qty Dashboard =====
+export interface DrinkProductionQtyJobOrderDetail {
+ jobOrderId: number;
+ jobOrderCode?: string | null;
+ productionDate?: string | null;
+ reqQty: number;
+ productionQty: number;
+}
+
+export interface DrinkProductionQtyResponse {
+ itemCode?: string | null;
+ itemName?: string | null;
+ uom?: string | null;
+ totalReqQty: number;
+ totalQty: number;
+ jobOrders?: DrinkProductionQtyJobOrderDetail[];
+}
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */
+export const fetchDrinkProductionQty = cache(async (date?: string) => {
+ const params = new URLSearchParams();
+ if (date) params.set("date", date);
+ const qs = params.toString();
+ const url = `${BASE_API_URL}/product-process/Demo/DrinkProductionQty${qs ? `?${qs}` : ""}`;
+
+ return serverFetchJson(url, {
+ method: "GET",
+ next: { tags: ["drinkProductionQty"] },
+ });
+});
+
// ===== Equipment Status Dashboard =====
export interface EquipmentStatusProcessInfo {
diff --git a/src/app/api/settings/item/actions.ts b/src/app/api/settings/item/actions.ts
index 3f4b782..f6b9180 100644
--- a/src/app/api/settings/item/actions.ts
+++ b/src/app/api/settings/item/actions.ts
@@ -95,6 +95,30 @@ export interface ItemWithDetails {
uomDesc: string;
currentStockBalance: number;
}
+
+export interface PickUomOption {
+ uomId: number;
+ uomCode: string;
+ uomDesc: string;
+}
+
+export interface AvailablePickUomsResponse {
+ itemId: number;
+ stockUom: PickUomOption | null;
+ options: PickUomOption[];
+}
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 */
+export const fetchAvailablePickUoms = cache(async (itemId: number) => {
+ return serverFetchJson(
+ `${BASE_API_URL}/items/${itemId}/available-pick-uoms`,
+ {
+ method: "GET",
+ next: { tags: ["items"] },
+ },
+ );
+});
+
export const fetchItemsWithDetails = cache(async (searchParams?: Record) => {
if (searchParams) {
const queryString = new URLSearchParams(searchParams).toString();
diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts
index 67e9ae4..aa44585 100644
--- a/src/app/utils/fetchUtil.ts
+++ b/src/app/utils/fetchUtil.ts
@@ -2,6 +2,9 @@ import { SessionWithTokens, authOptions } from "@/config/authConfig";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
+import { ServerFetchError } from "./serverFetchError";
+
+export { ServerFetchError };
export interface BlobResponse {
filename: string;
@@ -21,16 +24,6 @@ export interface searchParamsProps {
searchParams: { [key: string]: string | string[] | undefined };
}
-export class ServerFetchError extends Error {
- public readonly response: Response | undefined;
- constructor(message?: string, response?: Response) {
- super(message);
- this.response = response;
-
- Object.setPrototypeOf(this, ServerFetchError.prototype);
- }
-}
-
export async function serverFetchWithNoContent(...args: FetchParams) {
const response = await serverFetch(...args);
diff --git a/src/app/utils/serverFetchError.ts b/src/app/utils/serverFetchError.ts
new file mode 100644
index 0000000..d6dbc67
--- /dev/null
+++ b/src/app/utils/serverFetchError.ts
@@ -0,0 +1,21 @@
+/** Client-safe error type thrown by server fetch helpers. */
+export class ServerFetchError extends Error {
+ public readonly response: Response | undefined;
+
+ constructor(message?: string, response?: Response) {
+ super(message);
+ this.response = response;
+ Object.setPrototypeOf(this, ServerFetchError.prototype);
+ }
+}
+
+/** Works in client components after server actions (errors may lose prototype). */
+export const isNotFoundServerFetchError = (e: unknown): boolean => {
+ if (e instanceof ServerFetchError) {
+ return e.response?.status === 404;
+ }
+ if (e instanceof Error) {
+ return /\b404\b/.test(e.message);
+ }
+ return false;
+};
diff --git a/src/authorities.ts b/src/authorities.ts
index 57e4423..1107afd 100644
--- a/src/authorities.ts
+++ b/src/authorities.ts
@@ -9,6 +9,7 @@ export const AUTH = {
ADMIN: "ADMIN",
STOCK: "STOCK",
INVENTORY_ADJUST: "INVENTORY_ADJUST",
+ ITEM_TRACING: "ITEM_TRACING",
PURCHASE: "PURCHASE",
STOCK_TAKE: "STOCK_TAKE",
STOCK_IN_BIND: "STOCK_IN_BIND",
diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx
index f012cd6..20e30a7 100644
--- a/src/components/Breadcrumb/Breadcrumb.tsx
+++ b/src/components/Breadcrumb/Breadcrumb.tsx
@@ -47,6 +47,7 @@ const pathToLabelKey: { [path: string]: string } = {
"/scheduling/detailed": "nav.breadcrumb.schedulingDetailed",
"/scheduling/detailed/edit": "nav.breadcrumb.schedulingDetailedEdit",
"/inventory": "nav.breadcrumb.inventory",
+ "/itemTracing": "nav.breadcrumb.itemTracing",
"/settings/importTesting": "nav.breadcrumb.importTesting",
"/settings/m18ImportTesting": "nav.breadcrumb.importTesting",
"/do": "nav.deliveryOrder",
diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx
index 3e863ac..5c85e0c 100644
--- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx
+++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx
@@ -58,6 +58,7 @@ function TabPanel(props: { value: number; index: number; children: React.ReactNo
return {children};
}
+/** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */
const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCombo = [] }) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -74,6 +75,8 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom
urlTargetDateRaw && urlTargetDateRaw.trim() !== ""
? decodeURIComponent(urlTargetDateRaw.trim())
: null;
+ /** Item Tracing deep-link only; DO finish redirect must not set this. */
+ const urlOpenDetail = searchParams.get("openDetail") === "1";
const [tab, setTab] = React.useState(defaultTabIndex);
const [lanePanelPrefs, setLanePanelPrefs] = React.useState(
@@ -147,10 +150,11 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom
setTab(newTab);
const params = new URLSearchParams(searchParams.toString());
params.set("tab", String(newTab));
- /* ticketNo / targetDate deep-link only for "Finished Good Record" (mine) */
- if (newTab !== 2) {
+ /* ticketNo / targetDate / openDetail deep-link for Finished Good Record tabs */
+ if (newTab !== 2 && newTab !== 3) {
params.delete("ticketNo");
params.delete("targetDate");
+ params.delete("openDetail");
}
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
@@ -380,21 +384,26 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom
@@ -411,6 +420,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom
);
};
+/** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */
const DoWorkbenchTabs: React.FC = (props) => (
= ({
printerCombo,
listScope = "mine",
@@ -57,6 +60,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({
labelPrinter,
initialTicketNo,
initialTargetDate,
+ openDetail = false,
}) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -507,6 +511,17 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({
[],
);
+ const initialDetailOpenedRef = useRef(false);
+ useEffect(() => {
+ if (!openDetail) return;
+ if (initialDetailOpenedRef.current || loading || !initialTicketNo?.trim()) return;
+ const tn = initialTicketNo.trim();
+ const match = records.find((r) => r.ticketNo?.trim() === tn);
+ if (!match) return;
+ initialDetailOpenedRef.current = true;
+ void handleDetailClick(match);
+ }, [openDetail, records, loading, initialTicketNo, handleDetailClick]);
+
const handleBackToList = useCallback(() => {
setShowDetailView(false);
setSelectedRecord(null);
diff --git a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
index 0c1e3f3..76b722b 100644
--- a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
+++ b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
@@ -40,6 +40,7 @@ interface Props {
type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn };
type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] };
+/** FP-MTMS Version Checklist | Functions Ref. No. 36 | v1.0.0 | 2026-07-27 */
const WorkbenchFloorLanePanel: React.FC = ({
onPickOrderAssigned,
onSwitchToDetailTab,
@@ -85,7 +86,13 @@ const WorkbenchFloorLanePanel: React.FC = ({
const [modalReleaseTypeFilter, setModalReleaseTypeFilter] = useState(undefined);
const [modalFilterRequiredDeliveryDate, setModalFilterRequiredDeliveryDate] = useState(undefined);
const [modalInitialShopSearch, setModalInitialShopSearch] = useState(undefined);
- const defaultTruckCount = summary4F?.defaultTruckCount ?? 0;
+ const [modalTruckXFloor, setModalTruckXFloor] = useState(undefined);
+ const ticketFloorApiKey = useMemo(
+ () => ticketFloor.replace("/", "").trim().toUpperCase(),
+ [ticketFloor],
+ );
+ const defaultTruckCount =
+ (ticketFloor === "2/F" ? summary2F?.defaultTruckCount : summary4F?.defaultTruckCount) ?? 0;
const etraEnterInFlightRef = useRef(false);
const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false);
@@ -108,16 +115,25 @@ const WorkbenchFloorLanePanel: React.FC = ({
shopName?: string,
storeId?: string,
truck?: string,
- releaseType?: string
- ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType),
+ releaseType?: string,
+ floor?: string
+ ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType, floor),
loadToday: (
shopName?: string,
storeId?: string,
truck?: string,
requiredDeliveryDate?: string,
- releaseType?: string
+ releaseType?: string,
+ floor?: string
) =>
- fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate, releaseType),
+ fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
+ shopName,
+ storeId,
+ truck,
+ requiredDeliveryDate,
+ releaseType,
+ floor
+ ),
assignByListItemId: assignByDeliveryOrderPickOrderId,
}),
[],
@@ -234,7 +250,14 @@ const WorkbenchFloorLanePanel: React.FC = ({
pendingRef.current += 1;
startFullTimer();
try {
- const list = await fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, undefined, "車線-X");
+ // storeId stays undefined (Truck X); floor splits by DO supplier preferred floor.
+ const list = await fetchWorkbenchReleasedDoPickOrdersForSelection(
+ undefined,
+ undefined,
+ "車線-X",
+ undefined,
+ ticketFloorApiKey
+ );
setBeforeTodayTruckXCount(list.length);
} catch {
setBeforeTodayTruckXCount(0);
@@ -244,12 +267,13 @@ const WorkbenchFloorLanePanel: React.FC = ({
}
};
void loadBeforeTodayTruckX();
- }, [inEtraUi]);
+ }, [inEtraUi, ticketFloorApiKey]);
const clearModalEtraContext = useCallback(() => {
setModalReleaseTypeFilter(undefined);
setModalFilterRequiredDeliveryDate(undefined);
setModalInitialShopSearch(undefined);
+ setModalTruckXFloor(undefined);
}, []);
const openEnterEtraView = useCallback(async () => {
@@ -538,6 +562,7 @@ const WorkbenchFloorLanePanel: React.FC = ({
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
setDefaultDateScope("today");
+ setModalTruckXFloor(ticketFloorApiKey);
setModalOpen(true);
}}
>
@@ -638,10 +663,11 @@ const WorkbenchFloorLanePanel: React.FC = ({
variant="outlined"
onClick={() => {
clearModalEtraContext();
- setSelectedStore("4/F");
+ setSelectedStore("");
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
setDefaultDateScope("before");
+ setModalTruckXFloor(ticketFloorApiKey);
setModalOpen(true);
}}
>
@@ -664,11 +690,17 @@ const WorkbenchFloorLanePanel: React.FC = ({
group.lanes.map((lane, li) => {
const sid = (lane.storeId ?? "").trim();
const dep = (lane.truckDepartureTime ?? "").trim();
- const is4F = sid.replace(/\//g, "").toUpperCase() === "4F";
+ const isTruckX =
+ (lane.truckLanceCode ?? "").trim() === "車線-X" ||
+ (lane.truckLanceCode ?? "").trim() === "Truck X";
+ const floorKey = sid.replace(/\//g, "").toUpperCase();
+ const is4F = floorKey === "4F";
const labelCore =
- is4F && lane.loadingSequence != null
- ? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})`
- : `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`;
+ isTruckX
+ ? `${t("車線-X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})`
+ : is4F && lane.loadingSequence != null
+ ? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})`
+ : `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`;
const handlerName = (lane.handlerName ?? "").trim();
const shopCode = (group.shopCode ?? "").trim();
const shopName = (group.shopName ?? "").trim();
@@ -677,20 +709,29 @@ const WorkbenchFloorLanePanel: React.FC = ({
? `${shopCode} · ${shopName}`
: shopName || shopCode || t("Shop");
const laneSecondary = `${labelCore}${handlerName ? ` · ${handlerName}` : ""}`;
- const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${li}`;
+ const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${sid}|${li}`;
return (
@@ -4556,6 +4710,12 @@ paginatedData.map((row, index) => {
).trim() || null
: null
}
+ expectedUomId={
+ workbenchLotLabelContextLot != null &&
+ Number(workbenchLotLabelContextLot.uomId) > 0
+ ? Number(workbenchLotLabelContextLot.uomId)
+ : null
+ }
disableScanPick={workbenchLotLabelScanPickDisabled}
onWorkbenchScanPick={handleWorkbenchLotLabelScanPick}
submitQty={workbenchLotLabelSubmitQty}
diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
index e6abd6d..2863a5b 100644
--- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
+++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
@@ -63,6 +63,8 @@ type QrCodeAnalysisResponse = {
inventoryLotLineId: number;
warehouseCode?: string | null;
warehouseName?: string | null;
+ uom?: string | null;
+ uomId?: number | null;
} | null;
sameItemLots: Array<{
lotNo: string;
@@ -70,6 +72,7 @@ type QrCodeAnalysisResponse = {
stockInLineId?: number | null;
availableQty: number;
uom: string;
+ uomId?: number | null;
warehouseCode?: string | null;
warehouseName?: string | null;
}>;
@@ -95,6 +98,8 @@ export interface WorkbenchLotLabelPrintModalProps {
/** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */
triggerLotAvailableQty?: number | null;
triggerLotUom?: string | null;
+ /** POL UomConversion id — workbench lot list only returns matching UOM */
+ expectedUomId?: number | null;
/** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */
disableScanPick?: boolean;
/**
@@ -141,6 +146,7 @@ function isLabelPrinter(p: Printer): boolean {
return s.includes("label") && !s.includes("a4");
}
+/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */
const WorkbenchLotLabelPrintModal: React.FC = ({
open,
onClose,
@@ -155,6 +161,7 @@ const WorkbenchLotLabelPrintModal: React.FC =
hideTriggeredLot = false,
triggerLotAvailableQty = null,
triggerLotUom = null,
+ expectedUomId = null,
disableScanPick = false,
onWorkbenchScanPick,
submitQty = null,
@@ -257,13 +264,22 @@ const WorkbenchLotLabelPrintModal: React.FC =
if (id != null) setSelectedPrinterId(id);
}, [open, printers, selectedPrinterId, pickDefaultPrinterId]);
+ const resolveExpectedUomId = useCallback((): number | null => {
+ const n = Number(expectedUomId);
+ return Number.isFinite(n) && n > 0 ? n : null;
+ }, [expectedUomId]);
+
const analyzePayload = useCallback(
async (payload: ScanPayload) => {
setLastPayload(payload);
setScanError(null);
setAnalysisLoading(true);
try {
- const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse;
+ const uomId = resolveExpectedUomId();
+ const data = (await analyzeWorkbenchQrCode({
+ ...payload,
+ ...(uomId != null ? { uomId } : {}),
+ })) as QrCodeAnalysisResponse;
setAnalysis(data);
setSnackbar({
open: true,
@@ -277,7 +293,7 @@ const WorkbenchLotLabelPrintModal: React.FC =
setAnalysisLoading(false);
}
},
- [],
+ [resolveExpectedUomId],
);
const analyzeByItem = useCallback(
@@ -290,7 +306,11 @@ const WorkbenchLotLabelPrintModal: React.FC =
setScanError(null);
setAnalysisLoading(true);
try {
- const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as {
+ const uomId = resolveExpectedUomId();
+ const data = (await fetchWorkbenchAvailableLotsByItem(
+ itemId,
+ uomId,
+ )) as {
itemId: number;
itemCode: string;
itemName: string;
@@ -315,7 +335,7 @@ const WorkbenchLotLabelPrintModal: React.FC =
setAnalysisLoading(false);
}
},
- [],
+ [resolveExpectedUomId],
);
const handleAnalyze = useCallback(async () => {
@@ -381,30 +401,51 @@ const WorkbenchLotLabelPrintModal: React.FC =
Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0;
const fromApi = Number(scannedRow?.availableQty ?? 0);
const scanned = analysis.scanned;
- const scannedLot = scannedLotLineId
- ? {
- lotNo: scanned?.lotNo ?? "",
- inventoryLotLineId: scannedLotLineId,
- stockInLineId: Number(scanned?.stockInLineId ?? 0) || null,
- availableQty: Math.max(fromApi, fromTable) as number,
- uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string,
- warehouseCode:
- scanned?.warehouseCode ?? scannedRow?.warehouseCode,
- warehouseName:
- scanned?.warehouseName ?? scannedRow?.warehouseName,
- _scanned: true as const,
- }
- : null;
+ const expectUom = Number(expectedUomId);
+ const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0;
+ const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0);
+ const scannedUomOk =
+ !hasExpectUom ||
+ !Number.isFinite(scannedUomId) ||
+ scannedUomId <= 0 ||
+ scannedUomId === expectUom;
+ const scannedLot =
+ scannedLotLineId && scannedUomOk
+ ? {
+ lotNo: scanned?.lotNo ?? "",
+ inventoryLotLineId: scannedLotLineId,
+ stockInLineId: Number(scanned?.stockInLineId ?? 0) || null,
+ availableQty: Math.max(fromApi, fromTable) as number,
+ uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string,
+ uomId: scannedUomId > 0 ? scannedUomId : null,
+ warehouseCode:
+ scanned?.warehouseCode ?? scannedRow?.warehouseCode,
+ warehouseName:
+ scanned?.warehouseName ?? scannedRow?.warehouseName,
+ _scanned: true as const,
+ }
+ : null;
const merged = [
...(!hideTriggeredLot && scannedLot ? [scannedLot] : []),
...list
.filter((x) => x.inventoryLotLineId !== scannedLotLineId)
+ .filter((x) => {
+ if (!hasExpectUom) return true;
+ const id = Number(x.uomId);
+ return !Number.isFinite(id) || id <= 0 || id === expectUom;
+ })
.map((x) => ({ ...x, _scanned: false as const })),
];
return merged;
- }, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]);
+ }, [
+ analysis,
+ hideTriggeredLot,
+ triggerLotAvailableQty,
+ triggerLotUom,
+ expectedUomId,
+ ]);
const filteredLots = useMemo(() => {
const prefix = String(warehouseCodePrefixFilter ?? "").trim();
diff --git a/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx b/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx
index 53c824a..60f554b 100644
--- a/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx
+++ b/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx
@@ -5,13 +5,16 @@ import {
Box,
Card,
CardContent,
+ Checkbox,
Chip,
CircularProgress,
FormControl,
InputLabel,
+ ListItemText,
MenuItem,
Paper,
Select,
+ SelectChangeEvent,
Stack,
Table,
TableBody,
@@ -65,6 +68,14 @@ function showDoPickOpsButtons(row: WorkbenchTicketReleaseTable): boolean {
);
}
+const HANDLER_UNASSIGNED = "__UNASSIGNED__";
+const HANDLER_SELECT_ALL = "__ALL__";
+
+function handlerFilterKey(handlerName: string | null | undefined): string {
+ const name = handlerName?.trim() ?? "";
+ return name || HANDLER_UNASSIGNED;
+}
+
const WorkbenchTicketReleaseTableTab: React.FC = () => {
const { t } = useTranslation("ticketReleaseTable");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -73,6 +84,7 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
const [queryDate, setQueryDate] = useState(() => dayjs());
const [selectedFloor, setSelectedFloor] = useState("");
const [selectedStatus, setSelectedStatus] = useState("released");
+ const [selectedHandlers, setSelectedHandlers] = useState([]);
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [now, setNow] = useState(dayjs());
@@ -111,6 +123,28 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
}, []);
const dayStr = queryDate.format("YYYY-MM-DD");
+
+ const handlerOptions = useMemo(() => {
+ const names = new Set();
+ let hasUnassigned = false;
+ for (const item of data) {
+ if (selectedFloor && item.storeId !== selectedFloor) continue;
+ const name = item.handlerName?.trim();
+ if (name) names.add(name);
+ else hasUnassigned = true;
+ }
+ const sorted = Array.from(names).sort((a, b) => a.localeCompare(b, "zh-Hant"));
+ return hasUnassigned ? [HANDLER_UNASSIGNED, ...sorted] : sorted;
+ }, [data, selectedFloor]);
+
+ useEffect(() => {
+ setSelectedHandlers((prev) => {
+ if (prev.length === 0) return prev;
+ const next = prev.filter((name) => handlerOptions.includes(name));
+ return next.length === prev.length ? prev : next;
+ });
+ }, [handlerOptions]);
+
const filteredData = useMemo(() => {
return data.filter((item) => {
if (selectedFloor && item.storeId !== selectedFloor) return false;
@@ -119,9 +153,27 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
if (itemDate !== dayStr) return false;
}
if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) return false;
+ if (selectedHandlers.length > 0 && !selectedHandlers.includes(handlerFilterKey(item.handlerName))) {
+ return false;
+ }
return true;
});
- }, [data, dayStr, selectedFloor, selectedStatus]);
+ }, [data, dayStr, selectedFloor, selectedStatus, selectedHandlers]);
+
+ const allHandlersSelected =
+ handlerOptions.length > 0 && selectedHandlers.length === handlerOptions.length;
+
+ const handleHandlersChange = (event: SelectChangeEvent) => {
+ const value = event.target.value;
+ const next = typeof value === "string" ? value.split(",") : value;
+ if (next.includes(HANDLER_SELECT_ALL)) {
+ setSelectedHandlers(allHandlersSelected ? [] : handlerOptions);
+ setPaginationController((prev) => ({ ...prev, pageNum: 0 }));
+ return;
+ }
+ setSelectedHandlers(next.filter((v) => v !== HANDLER_SELECT_ALL));
+ setPaginationController((prev) => ({ ...prev, pageNum: 0 }));
+ };
const paginatedData = useMemo(() => {
const startIndex = paginationController.pageNum * paginationController.pageSize;
@@ -247,6 +299,39 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
+
+
+ {t("Handler Name")}
+
+
+
diff --git a/src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx b/src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx
index 125ece2..5f36509 100644
--- a/src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx
+++ b/src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx
@@ -36,7 +36,9 @@ export type ReleasedDoPickListBridge = {
shopName?: string,
storeId?: string,
truck?: string,
- releaseType?: string
+ releaseType?: string,
+ /** Optional `2F`/`4F` for Truck X supplier-floor split */
+ floor?: string
) => Promise;
/** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */
loadToday: (
@@ -44,7 +46,9 @@ export type ReleasedDoPickListBridge = {
storeId?: string,
truck?: string,
requiredDeliveryDate?: string,
- releaseType?: string
+ releaseType?: string,
+ /** Optional `2F`/`4F` for Truck X supplier-floor split */
+ floor?: string
) => Promise;
assignByListItemId: (userId: number, id: number) => Promise;
};
@@ -70,8 +74,13 @@ interface Props {
* requiredDate instead of historical released (delivery date before calendar today).
*/
filterRequiredDeliveryDate?: string;
+ /**
+ * Truck X only: `2F`/`4F` (or `2/F`/`4/F`) — filter by DO supplier preferred floor; storeId stays null.
+ */
+ truckXFloor?: string;
}
+/** FP-MTMS Version Checklist | Functions Ref. No. 36 | v1.0.0 | 2026-07-27 */
const ReleasedDoPickOrderSelectModal: React.FC = ({
open,
onClose,
@@ -85,6 +94,7 @@ const ReleasedDoPickOrderSelectModal: React.FC = ({
releaseTypeFilter,
initialShopSearch,
filterRequiredDeliveryDate,
+ truckXFloor,
}) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -102,21 +112,25 @@ const ReleasedDoPickOrderSelectModal: React.FC = ({
const loadReleased = listBridge?.loadBeforeToday ?? fetchReleasedDoPickOrdersForSelection;
const loadTodayFn = listBridge?.loadToday ?? fetchReleasedDoPickOrdersForSelectionToday;
+ const floorArg = truckXFloor?.trim() || undefined;
+ const shopArg = shopSearch.trim() || undefined;
if (isDefaultTruck) {
if (defaultDateScopeProp === "today") {
data = await loadTodayFn(
- undefined,
+ shopArg,
undefined,
"車線-X",
defaultTruckRequiredDeliveryDate?.trim() || undefined,
- releaseTypeFilter?.trim() || undefined
+ releaseTypeFilter?.trim() || undefined,
+ floorArg
);
} else {
data = await loadReleased(
- undefined,
+ shopArg,
undefined,
"車線-X",
- releaseTypeFilter?.trim() || undefined
+ releaseTypeFilter?.trim() || undefined,
+ floorArg
);
}
} else if (filterRequiredDeliveryDate?.trim() && listBridge?.loadToday) {
@@ -143,7 +157,7 @@ const ReleasedDoPickOrderSelectModal: React.FC = ({
} finally {
setLoading(false);
}
- }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate]);
+ }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate, truckXFloor]);
useEffect(() => {
loadList();
diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx
index 33becd7..778e6d6 100644
--- a/src/components/InventorySearch/InventorySearch.tsx
+++ b/src/components/InventorySearch/InventorySearch.tsx
@@ -12,7 +12,7 @@ import {
analyzeQrCode,
SearchInventory,
SearchInventoryLotLine,
- fetchInventories,
+ fetchInventoriesLatest,
fetchInventoryLotLines,
} from '@/app/api/inventory/actions';
import { PrinterCombo } from '@/app/api/settings/printer';
@@ -56,6 +56,7 @@ type SearchQuery = Partial<
>;
type SearchParamNames = keyof SearchQuery;
+/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */
const InventorySearch: React.FC = ({ inventories, printerCombo }) => {
const { t } = useTranslation(['inventory', 'common', 'item']);
@@ -171,12 +172,12 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => {
pagingController: typeof defaultPagingController,
lotNo: string,
) => {
- console.log('%c Action Type 1.', 'color:red', actionType);
+ //console.log('%c Action Type 1.', 'color:red', actionType);
// Avoid loading data again
if (actionType === 'paging' && pagingController === defaultPagingController) {
return;
}
- console.log('%c Action Type 2.', 'color:blue', actionType);
+ // console.log('%c Action Type 2.', 'color:blue', actionType);
const params: SearchInventory = {
code: query?.itemCode ?? '',
@@ -187,7 +188,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => {
pageSize: pagingController.pageSize,
};
- const response = await fetchInventories(params);
+ const response = await fetchInventoriesLatest(params);
if (response) {
setInventoriesTotalCount(response.total);
@@ -199,7 +200,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => {
break;
case 'paging':
setFilteredInventories((fi) =>
- uniqBy([...fi, ...response.records], 'id'),
+ uniqBy([...fi, ...response.records], 'itemId'),
);
}
}
@@ -414,7 +415,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => {
scanUiMode,
]);
- console.log('', 'color: #666', inventoriesPagingController);
+ //console.log('', 'color: #666', inventoriesPagingController);
const handleOpenOpeningInventoryModal = useCallback(() => {
setOpeningSelectedItem(null);
diff --git a/src/components/ItemPriceSearch/ItemPriceSearch.tsx b/src/components/ItemPriceSearch/ItemPriceSearch.tsx
index 54556e9..6d7f6fa 100644
--- a/src/components/ItemPriceSearch/ItemPriceSearch.tsx
+++ b/src/components/ItemPriceSearch/ItemPriceSearch.tsx
@@ -24,7 +24,7 @@ type ItemPriceSearchComponent = React.FC & {
type SearchParamNames = keyof SearchQuery;
const ItemPriceSearch: ItemPriceSearchComponent = () => {
- const { t } = useTranslation(["itemPrice", "inventory", "common", "importExcel"]);
+ const { t } = useTranslation(["itemPrice", "common", "importExcel"]);
const [item, setItem] = useState(null);
const [isSearching, setIsSearching] = useState(false);
@@ -275,7 +275,7 @@ const ItemPriceSearch: ItemPriceSearchComponent = () => {
- {t("Average unit price", { ns: "inventory" })}
+ {t("Average unit price")}
{avgPrice != null && avgPrice !== 0
@@ -292,7 +292,7 @@ const ItemPriceSearch: ItemPriceSearchComponent = () => {
- {t("Latest market unit price", { ns: "inventory" })}
+ {t("Latest market unit price")}
{item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0
diff --git a/src/components/ItemTracing/ItemTracing.tsx b/src/components/ItemTracing/ItemTracing.tsx
new file mode 100644
index 0000000..ea8e637
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracing.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import { useTranslation } from "react-i18next";
+import { fetchItemLotTrace } from "@/app/api/itemTracing/actions";
+import { ItemLotTraceResponse } from "@/app/api/itemTracing";
+import { isNotFoundServerFetchError } from "@/app/utils/serverFetchError";
+import ItemTracingScanBar from "./ItemTracingScanBar";
+import ItemTracingSummary from "./ItemTracingSummary";
+import ItemTracingFlowGraph from "./ItemTracingFlowGraph";
+import ItemTracingSections from "./ItemTracingSections";
+import {
+ compileTraceGraph,
+ type CompiledTraceGraph,
+} from "./compileTraceGraph";
+import { buildTraceGraphLabels } from "./traceGraphLabels";
+import { exportItemLotTraceXlsx } from "./exportItemLotTraceXlsx";
+
+export type WarehouseFocusRequest = {
+ inventoryLotId: number;
+ warehouseCode: string;
+};
+
+const ItemTracing: React.FC = () => {
+ const { t } = useTranslation("itemTracing");
+ const searchParams = useSearchParams();
+ const initialTraceRan = useRef(false);
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [focusWarehouse, setFocusWarehouse] =
+ useState(null);
+
+ const runTrace = useCallback(
+ async (params: {
+ stockInLineId?: number;
+ inventoryLotLineId?: number;
+ lotNo?: string;
+ itemCode?: string;
+ }) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetchItemLotTrace(params);
+ setData(res);
+ setFocusWarehouse(null);
+ } catch (e) {
+ console.error(e);
+ if (isNotFoundServerFetchError(e)) {
+ setError(t("notFound"));
+ } else {
+ setError(t("traceError"));
+ }
+ setData(null);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [t],
+ );
+
+ useEffect(() => {
+ if (initialTraceRan.current) return;
+ const stockInLineIdRaw = searchParams.get("stockInLineId");
+ const lotNo = searchParams.get("lotNo")?.trim();
+ const itemCode = searchParams.get("itemCode")?.trim();
+ const stockInLineId = stockInLineIdRaw
+ ? Number(stockInLineIdRaw)
+ : undefined;
+ const hasTraceParams =
+ (stockInLineId != null && !Number.isNaN(stockInLineId)) ||
+ Boolean(lotNo) ||
+ Boolean(itemCode);
+ if (!hasTraceParams) return;
+ initialTraceRan.current = true;
+ void runTrace({
+ stockInLineId:
+ stockInLineId != null && !Number.isNaN(stockInLineId)
+ ? stockInLineId
+ : undefined,
+ lotNo: lotNo || undefined,
+ itemCode: itemCode || undefined,
+ });
+ }, [runTrace, searchParams]);
+
+ const compiledGraph = useMemo((): CompiledTraceGraph | null => {
+ if (!data) return null;
+ const labels = buildTraceGraphLabels(t, data.lot.uom);
+ return compileTraceGraph(data, labels);
+ }, [data, t]);
+
+ const handleExportExcel = useCallback(() => {
+ if (!data || !compiledGraph) return;
+ exportItemLotTraceXlsx(data, compiledGraph, t);
+ }, [data, compiledGraph, t]);
+
+ return (
+
+
+ {t("subtitle")}
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {error && !loading && {error}}
+
+ {!loading && !data && !error && (
+ {t("noResult")}
+ )}
+
+ {data && !loading && (
+ <>
+ setFocusWarehouse(request)}
+ onExportExcel={compiledGraph ? handleExportExcel : undefined}
+ />
+ {compiledGraph && (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+
+ );
+};
+
+export default ItemTracing;
diff --git a/src/components/ItemTracing/ItemTracingDocLink.tsx b/src/components/ItemTracing/ItemTracingDocLink.tsx
new file mode 100644
index 0000000..e4a2e1b
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingDocLink.tsx
@@ -0,0 +1,94 @@
+"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. 16 | v1.0.1 | 2026-07-19 */
+const ItemTracingDocLink: React.FC = ({
+ kind,
+ code,
+ id,
+ consoCode,
+ ticketNo,
+ targetDate,
+ workbenchTab = 3,
+ jodetailTab = 1,
+ openInNewTab = false,
+}) => {
+ if (!code?.trim()) return <>—>;
+
+ const linkTargetDate = normalizeTargetDateForLink(targetDate);
+
+ let href = "#";
+ if (kind === "jo" && id) href = `/jo/edit?id=${id}`;
+ else if (kind === "po" && id) href = `/po/edit?id=${id}`;
+ else if (kind === "workbench" && ticketNo?.trim()) {
+ const params = new URLSearchParams();
+ params.set("tab", String(workbenchTab));
+ params.set("ticketNo", ticketNo.trim());
+ if (linkTargetDate) params.set("targetDate", linkTargetDate);
+ // Only Item Tracing deep-links should auto-open detail (not DO finish redirect).
+ params.set("openDetail", "1");
+ href = `/doworkbench?${params.toString()}`;
+ } else if (kind === "jodetail" && (consoCode || code)) {
+ const params = new URLSearchParams();
+ 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);
+ params.set("openDetail", "1");
+ href = `/jodetail?${params.toString()}`;
+ } else if (kind === "pick") {
+ // /pickOrder/detail?consoCode=… is retired — only link when we have a TI-* workbench ticket.
+ 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);
+ params.set("openDetail", "1");
+ href = `/doworkbench?${params.toString()}`;
+ } else {
+ return <>{code}>;
+ }
+ } else {
+ return <>{code}>;
+ }
+
+ return (
+
+ {code}
+
+ );
+};
+
+export default ItemTracingDocLink;
diff --git a/src/components/ItemTracing/ItemTracingFlowGraph.tsx b/src/components/ItemTracing/ItemTracingFlowGraph.tsx
new file mode 100644
index 0000000..6fbff90
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingFlowGraph.tsx
@@ -0,0 +1,797 @@
+"use client";
+
+import "@xyflow/react/dist/style.css";
+
+import {
+ Background,
+ BackgroundVariant,
+ Controls,
+ MiniMap,
+ MarkerType,
+ Panel,
+ ReactFlow,
+ ReactFlowProvider,
+ useEdgesState,
+ useNodesState,
+ useReactFlow,
+ type Node,
+} from "@xyflow/react";
+import {
+ Box,
+ Chip,
+ IconButton,
+ Paper,
+ Popover,
+ Stack,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
+import MapOutlinedIcon from "@mui/icons-material/MapOutlined";
+import RestartAltIcon from "@mui/icons-material/RestartAlt";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ItemLotTraceResponse } from "@/app/api/itemTracing";
+import ItemTracingNodeDetailPanel from "./ItemTracingNodeDetailPanel";
+import ItemTracingFlowGraphSearch from "./ItemTracingFlowGraphSearch";
+import {
+ buildReactFlowGraph,
+ reactFlowGraphExtent,
+ type TraceFlowNodeData,
+} from "./buildReactFlowGraph";
+import { traceFlowNodeTypes } from "./TraceFlowNodes";
+import { traceFlowEdgeTypes } from "./TraceFlowEdge";
+import {
+ VIEWPORT_HEIGHT,
+ FIT_VIEW_MIN_ZOOM,
+ FIT_VIEW_MAX_ZOOM,
+ DO_GROUP_HEADER,
+} from "./traceFlowConstants";
+import {
+ minimapNodeColor,
+ phaseChipColor,
+ phaseLabelKey,
+ kindLabelKey,
+} from "./traceFlowNodeUtils";
+import { searchTraceGraphNodes } from "./traceGraphSearch";
+import type { CompiledTraceGraph } from "./compileTraceGraph";
+import { TraceGraphNode } from "./buildTraceGraphNodes";
+import type { WarehouseFocusRequest } from "./ItemTracing";
+import type { TraceGraphPhase } from "./traceGraphLayout";
+
+type TraceParams = {
+ stockInLineId?: number;
+ lotNo?: string;
+ itemCode?: string;
+};
+
+type Props = {
+ data: ItemLotTraceResponse;
+ compiledGraph: CompiledTraceGraph;
+ onTrace?: (params: TraceParams) => void;
+ focusWarehouse?: WarehouseFocusRequest | null;
+};
+
+const DETAIL_PANEL_WIDTH = 280;
+
+const LEGEND_ROWS: Array<{ key: string; tipKey: string }> = [
+ { key: "flowLegendTime", tipKey: "flowLegendTimeTip" },
+ { key: "flowLegendPhase", tipKey: "flowLegendPhaseTip" },
+ { key: "flowLegendBranch", tipKey: "flowLegendBranchTip" },
+ { key: "flowLegendArrow", tipKey: "flowLegendArrowTip" },
+ { key: "flowLegendPrelude", tipKey: "flowLegendPreludeTip" },
+];
+
+const ItemTracingFlowGraphInner: React.FC = ({
+ data,
+ compiledGraph,
+ onTrace,
+ focusWarehouse,
+}) => {
+ const { t } = useTranslation("itemTracing");
+ const { fitView } = useReactFlow();
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [showMinimap, setShowMinimap] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [activeMatchIndex, setActiveMatchIndex] = useState(0);
+ const [legendAnchor, setLegendAnchor] = useState(null);
+ const [hiddenPhases, setHiddenPhases] = useState>(
+ () => new Set(),
+ );
+ const [collapsedGroupIds, setCollapsedGroupIds] = useState>(
+ () => new Set(),
+ );
+
+ const layout = compiledGraph.layout;
+ const hasJoPrelude = Boolean(data.joPrelude);
+
+ const phaseLabels = useMemo(
+ () =>
+ Object.fromEntries(
+ layout.phaseOrder.map((phase) => [phase, t(phaseLabelKey(phase))]),
+ ),
+ [layout.phaseOrder, t],
+ );
+
+ const graphElements = useMemo(
+ () => buildReactFlowGraph(layout, phaseLabels, compiledGraph.edgePairs),
+ [layout, phaseLabels, compiledGraph.edgePairs],
+ );
+
+ const nodesWithCallbacks = useMemo(
+ () =>
+ graphElements.nodes.map((node) =>
+ node.type === "traceEvent"
+ ? {
+ ...node,
+ data: { ...node.data, onTrace },
+ }
+ : node,
+ ),
+ [graphElements.nodes, onTrace],
+ );
+
+ const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks);
+ const [edges, setEdges, onEdgesChange] = useEdgesState(graphElements.edges);
+
+ const kindLabel = useCallback(
+ (kind: TraceGraphNode["kind"]) => t(kindLabelKey(kind)),
+ [t],
+ );
+
+ const phaseVisible = useCallback(
+ (phase: TraceGraphPhase | undefined) =>
+ phase == null || hiddenPhases.size === 0 || !hiddenPhases.has(phase),
+ [hiddenPhases],
+ );
+
+ const visibleLayoutNodes = useMemo(
+ () => layout.nodes.filter((n) => phaseVisible(n.phase)),
+ [layout.nodes, phaseVisible],
+ );
+
+ const matchIds = useMemo(
+ () => searchTraceGraphNodes(visibleLayoutNodes, searchQuery, kindLabel),
+ [visibleLayoutNodes, searchQuery, kindLabel],
+ );
+
+ const activeNodeId =
+ matchIds.length > 0 ? matchIds[activeMatchIndex % matchIds.length] : null;
+
+ const focusFitNodes = useMemo(() => {
+ if (!activeNodeId) return [];
+ const ids = new Set([activeNodeId]);
+ const activeRf = graphElements.nodes.find((n) => n.id === activeNodeId);
+ if (activeRf?.parentId) ids.add(activeRf.parentId);
+ graphElements.edges.forEach((edge) => {
+ if (edge.source === activeNodeId) ids.add(edge.target);
+ if (edge.target === activeNodeId) ids.add(edge.source);
+ });
+ return Array.from(ids, (id) => ({ id }));
+ }, [activeNodeId, graphElements.edges, graphElements.nodes]);
+
+ useEffect(() => {
+ setSearchQuery("");
+ setActiveMatchIndex(0);
+ setSelectedNode(null);
+ setHiddenPhases(new Set());
+ setCollapsedGroupIds(new Set());
+ }, [data]);
+
+ useEffect(() => {
+ setActiveMatchIndex(0);
+ }, [searchQuery]);
+
+ const togglePhase = useCallback(
+ (phase: TraceGraphPhase) => {
+ setHiddenPhases((prev) => {
+ const next = new Set(prev);
+ if (next.has(phase)) next.delete(phase);
+ else next.add(phase);
+ // If every phase would be hidden, treat as "show all"
+ if (next.size >= layout.phaseOrder.length) return new Set();
+ return next;
+ });
+ },
+ [layout.phaseOrder.length],
+ );
+
+ const toggleGroupCollapse = useCallback((groupId: string) => {
+ setCollapsedGroupIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupId)) next.delete(groupId);
+ else next.add(groupId);
+ return next;
+ });
+ }, []);
+
+ const resetPhaseFilter = useCallback(() => setHiddenPhases(new Set()), []);
+
+ const displayedNodes = useMemo(() => {
+ const hasSearch = searchQuery.trim().length > 0;
+ const matchSet = new Set(matchIds);
+ const groupIdsWithChildMatch = new Set();
+ visibleLayoutNodes.forEach((n) => {
+ if (n.doGroupId && matchSet.has(n.id))
+ groupIdsWithChildMatch.add(n.doGroupId);
+ });
+ const visibleIds = new Set(visibleLayoutNodes.map((n) => n.id));
+ layout.nodes.forEach((n) => {
+ if (n.doGroupId && visibleIds.has(n.id)) visibleIds.add(n.doGroupId);
+ });
+
+ return nodes
+ .filter((node) => {
+ if (node.type === "phaseLabel" || node.type === "dateHeader")
+ return true;
+ if (node.type === "doGroup" || node.type === "pickGroup") {
+ return layout.nodes.some(
+ (n) => n.doGroupId === node.id && phaseVisible(n.phase),
+ );
+ }
+ if (node.type === "traceEvent") {
+ const layoutNode = (node.data as TraceFlowNodeData | undefined)
+ ?.layoutNode;
+ if (
+ layoutNode?.doGroupId &&
+ collapsedGroupIds.has(layoutNode.doGroupId)
+ ) {
+ return false;
+ }
+ return phaseVisible(layoutNode?.phase);
+ }
+ return true;
+ })
+ .map((node) => {
+ if (
+ node.type !== "traceEvent" &&
+ node.type !== "doGroup" &&
+ node.type !== "pickGroup"
+ ) {
+ return node;
+ }
+ const isMatch =
+ matchSet.has(node.id) ||
+ ((node.type === "doGroup" || node.type === "pickGroup") &&
+ groupIdsWithChildMatch.has(node.id));
+ const isFocused = node.id === activeNodeId;
+ const isSelected = selectedNode?.id === node.id;
+ const isGroup = node.type === "doGroup" || node.type === "pickGroup";
+ const groupCollapsed = isGroup && collapsedGroupIds.has(node.id);
+ const collapsedH = DO_GROUP_HEADER;
+ return {
+ ...node,
+ ...(groupCollapsed
+ ? {
+ style: { ...node.style, height: collapsedH },
+ height: collapsedH,
+ measured: {
+ width: node.measured?.width ?? node.width ?? 0,
+ height: collapsedH,
+ },
+ }
+ : {}),
+ data: {
+ ...node.data,
+ searchActive: hasSearch,
+ searchMatch: isMatch,
+ searchFocused: isFocused,
+ nodeSelected: isSelected,
+ ...(isGroup
+ ? {
+ groupCollapsed,
+ onToggleGroupCollapse: () => toggleGroupCollapse(node.id),
+ }
+ : {}),
+ },
+ };
+ });
+ }, [
+ nodes,
+ searchQuery,
+ matchIds,
+ activeNodeId,
+ selectedNode?.id,
+ layout.nodes,
+ visibleLayoutNodes,
+ phaseVisible,
+ collapsedGroupIds,
+ toggleGroupCollapse,
+ ]);
+
+ const displayedEdges = useMemo(() => {
+ const visibleEventIds = new Set(
+ displayedNodes
+ .filter(
+ (n) =>
+ n.type === "traceEvent" ||
+ n.type === "doGroup" ||
+ n.type === "pickGroup",
+ )
+ .map((n) => n.id),
+ );
+ const phaseFiltered = edges.filter(
+ (edge) =>
+ visibleEventIds.has(edge.source) && visibleEventIds.has(edge.target),
+ );
+
+ const hasSearch = searchQuery.trim().length > 0 && matchIds.length > 0;
+ const selectedId = selectedNode?.id ?? null;
+ const selectedGroupId = selectedNode?.doGroupId?.trim() || null;
+ const hasSelection = Boolean(selectedId);
+ if (!hasSearch && !hasSelection) return phaseFiltered;
+
+ const matchSet = hasSearch ? new Set(matchIds) : null;
+ const selectionIds = new Set();
+ if (selectedId) {
+ selectionIds.add(selectedId);
+ if (selectedGroupId) selectionIds.add(selectedGroupId);
+ }
+
+ const focusStroke = "#1565c0";
+ const matchStroke = "#ef6c00";
+ const selectStroke = "#2e7d32";
+
+ return phaseFiltered.map((edge) => {
+ const sourceMatch = matchSet?.has(edge.source) ?? false;
+ const targetMatch = matchSet?.has(edge.target) ?? false;
+ const connectsHighlighted =
+ Boolean(matchSet) && sourceMatch && targetMatch;
+ const touchesFocus = Boolean(
+ activeNodeId &&
+ (edge.source === activeNodeId || edge.target === activeNodeId),
+ );
+ const touchesSelection =
+ hasSelection &&
+ (selectionIds.has(edge.source) || selectionIds.has(edge.target));
+ const shouldHighlight =
+ connectsHighlighted || touchesFocus || touchesSelection;
+
+ if (!shouldHighlight) {
+ return {
+ ...edge,
+ zIndex: 0,
+ style: {
+ ...edge.style,
+ stroke: "#bdbdbd",
+ strokeWidth: 1,
+ opacity: 0.18,
+ },
+ labelStyle: { ...edge.labelStyle, opacity: 0.2 },
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ color: "#bdbdbd",
+ width: 14,
+ height: 14,
+ },
+ };
+ }
+
+ const stroke = touchesSelection
+ ? selectStroke
+ : touchesFocus
+ ? focusStroke
+ : matchStroke;
+ const strokeWidth = touchesSelection || touchesFocus ? 3 : 2.5;
+ const zIndex = touchesSelection ? 4 : touchesFocus ? 3 : 2;
+
+ return {
+ ...edge,
+ zIndex,
+ style: {
+ ...edge.style,
+ stroke,
+ strokeWidth,
+ opacity: 1,
+ },
+ labelStyle: {
+ ...edge.labelStyle,
+ fill: touchesSelection
+ ? selectStroke
+ : touchesFocus
+ ? focusStroke
+ : "#e65100",
+ fontWeight: 700,
+ opacity: 1,
+ },
+ labelBgStyle: {
+ ...edge.labelBgStyle,
+ fill: touchesSelection
+ ? "#e8f5e9"
+ : touchesFocus
+ ? "#e3f2fd"
+ : "#fff8e1",
+ fillOpacity: 1,
+ },
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ color: stroke,
+ width: touchesSelection || touchesFocus ? 18 : 16,
+ height: touchesSelection || touchesFocus ? 18 : 16,
+ },
+ };
+ });
+ }, [
+ edges,
+ searchQuery,
+ matchIds,
+ activeNodeId,
+ selectedNode,
+ displayedNodes,
+ ]);
+
+ useEffect(() => {
+ setNodes(nodesWithCallbacks);
+ setEdges(graphElements.edges);
+ const timer = window.setTimeout(() => {
+ fitView({
+ padding: 0.04,
+ minZoom: FIT_VIEW_MIN_ZOOM,
+ maxZoom: FIT_VIEW_MAX_ZOOM,
+ duration: 200,
+ });
+ }, 50);
+ return () => window.clearTimeout(timer);
+ }, [nodesWithCallbacks, graphElements.edges, setNodes, setEdges, fitView]);
+
+ useEffect(() => {
+ if (!activeNodeId || !searchQuery.trim()) return;
+ const timer = window.setTimeout(() => {
+ fitView({
+ nodes: focusFitNodes,
+ padding: 0.55,
+ duration: 280,
+ maxZoom: 1.25,
+ });
+ }, 60);
+ return () => window.clearTimeout(timer);
+ }, [activeNodeId, searchQuery, focusFitNodes, fitView]);
+
+ useEffect(() => {
+ if (!focusWarehouse) return;
+ const { inventoryLotId, warehouseCode } = focusWarehouse;
+ const locPrefix = `loc-${inventoryLotId}-`;
+ const wh = warehouseCode.trim().toUpperCase();
+ const focusIds = layout.nodes
+ .filter((n) => {
+ if (n.id.startsWith(locPrefix)) return true;
+ if (inventoryLotId !== data.lot.inventoryLotId) return false;
+ if (n.id.startsWith("loc-")) return false;
+ if (!wh) return true;
+ return (n.warehouseCode ?? "").trim().toUpperCase() === wh;
+ })
+ .map((n) => n.id);
+ if (focusIds.length === 0) return;
+ setSearchQuery(warehouseCode.trim() || String(inventoryLotId));
+ setActiveMatchIndex(0);
+ const timer = window.setTimeout(() => {
+ fitView({
+ nodes: focusIds.map((id) => ({ id })),
+ padding: 0.2,
+ duration: 320,
+ maxZoom: 1.1,
+ });
+ }, 80);
+ return () => window.clearTimeout(timer);
+ }, [focusWarehouse, layout.nodes, data.lot.inventoryLotId, fitView]);
+
+ const handleSearchPrev = useCallback(() => {
+ if (matchIds.length === 0) return;
+ setActiveMatchIndex(
+ (prev) => (prev - 1 + matchIds.length) % matchIds.length,
+ );
+ }, [matchIds.length]);
+
+ const handleSearchNext = useCallback(() => {
+ if (matchIds.length === 0) return;
+ setActiveMatchIndex((prev) => (prev + 1) % matchIds.length);
+ }, [matchIds.length]);
+
+ const handleSearchClear = useCallback(() => {
+ setSearchQuery("");
+ setActiveMatchIndex(0);
+ }, []);
+
+ const onNodeClick = useCallback(
+ (_: React.MouseEvent, node: Node) => {
+ if (node.type === "traceEvent" && node.data?.layoutNode?.id) {
+ setSelectedNode(node.data.layoutNode);
+ }
+ },
+ [],
+ );
+
+ const translateExtent = useMemo(
+ () => reactFlowGraphExtent(layout, graphElements.graphHeight),
+ [layout, graphElements.graphHeight],
+ );
+
+ if (!layout.nodes.length) {
+ return (
+
+ {t("noRecords")}
+
+ );
+ }
+
+ return (
+
+
+
+ {t("flowGraph")}
+
+
+ setLegendAnchor(e.currentTarget)}
+ >
+
+
+
+
+
+
+ {hasJoPrelude ? t("flowGraphHintJo") : t("flowGraphHint")}
+ {" · "}
+ {t("flowZoomPanHint")}
+
+
+
+
+ {t("flowPhaseFilter")}
+
+
+ {layout.phaseOrder.map((phase) => {
+ const active = phaseVisible(phase);
+ const chipColor = phaseChipColor(phase);
+ return (
+ togglePhase(phase)}
+ sx={{
+ borderRadius: 1.5,
+ height: 28,
+ fontWeight: 600,
+ cursor: "pointer",
+ letterSpacing: 0.1,
+ opacity: active ? 1 : 0.8,
+ transition: "box-shadow 0.15s ease, opacity 0.15s ease, filter 0.15s ease",
+ "& .MuiChip-label": { px: 1.25 },
+ "&:hover": {
+ opacity: 1,
+ boxShadow: 1,
+ filter: active ? "brightness(1.06)" : undefined,
+ },
+ }}
+ />
+ );
+ })}
+ }
+ label={t("flowPhaseFilterReset")}
+ color="error"
+ variant="filled"
+ disabled={hiddenPhases.size === 0}
+ onClick={hiddenPhases.size > 0 ? resetPhaseFilter : undefined}
+ sx={{
+ borderRadius: 1.5,
+ height: 28,
+ fontWeight: 700,
+ cursor: hiddenPhases.size === 0 ? "default" : "pointer",
+ letterSpacing: 0.2,
+ "& .MuiChip-icon": { ml: 0.5, fontSize: 16 },
+ "& .MuiChip-label": { px: 1.25 },
+ "&:hover":
+ hiddenPhases.size === 0
+ ? undefined
+ : {
+ boxShadow: 1,
+ filter: "brightness(1.08)",
+ },
+ }}
+ />
+
+
+
+ setLegendAnchor(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
+ transformOrigin={{ vertical: "top", horizontal: "right" }}
+ >
+
+
+ {t("flowLegendTitle")}
+
+
+ {LEGEND_ROWS.map(({ key, tipKey }) => (
+
+
+ {t(key)}
+
+
+ {t(tipKey)}
+
+
+ ))}
+ {hasJoPrelude && (
+
+ {t("flowGraphPathJo")}
+
+ )}
+
+ {t("flowNodeDetailHint")}
+
+
+
+
+
+
+ {selectedNode && (
+
+ setSelectedNode(null)}
+ />
+
+ )}
+
+ setSelectedNode(null)}
+ nodesDraggable={false}
+ nodesConnectable={false}
+ elementsSelectable
+ panOnScroll
+ zoomOnScroll
+ minZoom={0.15}
+ maxZoom={2.5}
+ translateExtent={translateExtent}
+ proOptions={{ hideAttribution: true }}
+ style={{ width: "100%", height: "100%" }}
+ >
+
+
+
+
+
+
+
+ setShowMinimap((v) => !v)}
+ aria-label={
+ showMinimap ? t("flowMinimapHide") : t("flowMinimapShow")
+ }
+ sx={{
+ bgcolor: "background.paper",
+ border: 1,
+ borderColor: "divider",
+ boxShadow: 1,
+ borderRadius: 1,
+ width: 28,
+ height: 28,
+ "&:hover": { bgcolor: "background.paper" },
+ }}
+ >
+
+
+
+
+
+
+
+
+ {showMinimap && (
+ {
+ if (
+ node.type !== "traceEvent" &&
+ node.type !== "doGroup" &&
+ node.type !== "pickGroup"
+ ) {
+ return "#e0e0e0";
+ }
+ const layoutNode = (
+ node.data as TraceFlowNodeData | undefined
+ )?.layoutNode;
+ return layoutNode?.kind
+ ? minimapNodeColor(layoutNode.kind)
+ : "#e0e0e0";
+ }}
+ pannable
+ zoomable
+ style={{ border: "1px solid #e0e0e0", borderRadius: 4 }}
+ />
+ )}
+
+
+
+
+ );
+};
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 14 | v1.0.0 | 2026-07-17 */
+const ItemTracingFlowGraph: React.FC = (props) => (
+
+
+
+);
+
+export default ItemTracingFlowGraph;
diff --git a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx
new file mode 100644
index 0000000..58fbbe0
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx
@@ -0,0 +1,149 @@
+"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. 15 | v1.0.1 | 2026-07-20 */
+const ItemTracingFlowGraphSearch: React.FC = ({
+ query,
+ matchCount,
+ activeIndex,
+ onQueryChange,
+ onPrev,
+ onNext,
+ onClear,
+}) => {
+ const { t } = useTranslation("itemTracing");
+ const trimmed = query.trim();
+ const hasQuery = trimmed.length > 0;
+ const matchLabel =
+ matchCount > 0
+ ? t("flowGraphSearchMatch", { current: activeIndex + 1, total: matchCount })
+ : hasQuery
+ ? t("flowGraphSearchNoMatch")
+ : "";
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ if (event.shiftKey) onPrev();
+ else onNext();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ onClear();
+ }
+ };
+
+ return (
+
+ onQueryChange(e.target.value)}
+ onKeyDown={handleKeyDown}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: hasQuery ? (
+
+
+
+
+
+
+
+ ) : undefined,
+ }}
+ sx={{
+ "& .MuiOutlinedInput-root": {
+ alignItems: "center",
+ },
+ "& .MuiOutlinedInput-input": {
+ color: "text.secondary",
+ py: "8.5px",
+ lineHeight: 1.4375,
+ },
+ "& .MuiOutlinedInput-input::placeholder": {
+ color: "text.disabled",
+ opacity: 1,
+ },
+ "& .MuiInputAdornment-root": {
+ height: "100%",
+ maxHeight: "none",
+ alignItems: "center",
+ },
+ }}
+ />
+ {hasQuery && (
+
+ 0 ? "text.secondary" : "error"} noWrap>
+ {matchLabel}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default ItemTracingFlowGraphSearch;
diff --git a/src/components/ItemTracing/ItemTracingLoading.tsx b/src/components/ItemTracing/ItemTracingLoading.tsx
new file mode 100644
index 0000000..e8834d6
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingLoading.tsx
@@ -0,0 +1,11 @@
+import { Skeleton, Stack } from "@mui/material";
+
+const ItemTracingLoading: React.FC = () => (
+
+
+
+
+
+);
+
+export default ItemTracingLoading;
diff --git a/src/components/ItemTracing/ItemTracingLocations.tsx b/src/components/ItemTracing/ItemTracingLocations.tsx
new file mode 100644
index 0000000..a3d460d
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingLocations.tsx
@@ -0,0 +1,791 @@
+"use client";
+
+import React, { useState } from "react";
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Box,
+ Chip,
+ Stack,
+ TableContainer,
+ Typography,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import Inventory2Icon from "@mui/icons-material/Inventory2";
+import { useTranslation } from "react-i18next";
+import { ItemLotTraceLocationBlock } from "@/app/api/itemTracing";
+import type { WarehouseFocusRequest } from "./ItemTracing";
+import { blockWarehouseCode } from "./buildLocationBlockGraphNodes";
+import { createTraceLabelTranslator } from "./traceLabelUtils";
+import {
+ resolveStockTakeAcceptedQty,
+ resolveStockTakeBookQty,
+} from "./traceStockTakeUtils";
+import {
+ FilterableDataTable,
+ type FilterableColumnDef,
+} from "./itemTracingTableFilters";
+
+interface Props {
+ locationBlocks: ItemLotTraceLocationBlock[];
+ onFocusWarehouse?: (request: WarehouseFocusRequest) => void;
+}
+
+/** Compact sub-table for sections that may have many rows. */
+function SectionTable({
+ title,
+ rows,
+ columns,
+ getRowKey,
+}: {
+ title: string;
+ rows: T[];
+ columns: FilterableColumnDef[];
+ getRowKey: (row: T, index: number) => string;
+}) {
+ const { t } = useTranslation("itemTracing");
+ const [showAll, setShowAll] = useState(false);
+
+ return (
+
+
+ {title} ({rows.length})
+
+
+ setShowAll((v) => !v)}
+ showAllLabel={(count) => t("locationsShowAll", { count })}
+ collapseLabel={(count) => t("locationsCollapse", { count })}
+ />
+
+
+ );
+}
+
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */
+const ItemTracingLocations: React.FC = ({
+ locationBlocks,
+ onFocusWarehouse,
+}) => {
+ const { t } = useTranslation("itemTracing");
+ const tr = createTraceLabelTranslator(t);
+
+ if (!locationBlocks || locationBlocks.length === 0) return null;
+
+ return (
+
+
+
+
+ {t("locationHeader", { count: locationBlocks.length })}
+
+
+
+
+ {locationBlocks.map((block) => {
+ const totalIn = block.warehouseLines.reduce(
+ (s, l) => s + (l.inQty || 0),
+ 0,
+ );
+ const totalOut = block.warehouseLines.reduce(
+ (s, l) => s + (l.outQty || 0),
+ 0,
+ );
+ const available = totalIn - totalOut;
+ const lastMovement = block.movements[0];
+ const stockTakeVariance = block.stockTakeEvents.reduce(
+ (s, st) => s + (st.varianceQty || 0),
+ 0,
+ );
+
+ return (
+
+ }>
+
+
+ {block.warehouseLines
+ .map((l) => l.warehouseCode)
+ .filter(Boolean)
+ .join(" / ") || `Lot #${block.inventoryLotId}`}
+
+ {block.itemName && (
+
+ {block.itemCode} ??{block.itemName}
+
+ )}
+ 0 ? "success" : "default"}
+ variant="outlined"
+ />
+
+ {t("inQty")}: {totalIn} / {t("outQty")}: {totalOut}
+
+ {block.movements.length > 0 && (
+
+ )}
+ {lastMovement?.timestamp && (
+
+ {t("lastMove")}: {lastMovement.timestamp}
+
+ )}
+ {stockTakeVariance !== 0 && (
+
+ )}
+ {onFocusWarehouse ? (
+ {
+ e.stopPropagation();
+ onFocusWarehouse({
+ inventoryLotId: block.inventoryLotId,
+ warehouseCode: blockWarehouseCode(block),
+ });
+ }}
+ sx={{ ml: "auto" }}
+ />
+ ) : null}
+
+
+
+
+
+ {block.warehouseLines.length > 0 && (
+ String(wl.inventoryLotLineId)}
+ columns={[
+ {
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (wl) => wl.warehouseCode,
+ },
+ {
+ key: "inQty",
+ label: t("inQty"),
+ align: "right",
+ value: (wl) => wl.inQty,
+ },
+ {
+ key: "outQty",
+ label: t("outQty"),
+ align: "right",
+ value: (wl) => wl.outQty,
+ },
+ {
+ key: "available",
+ label: t("available"),
+ align: "right",
+ value: (wl) => wl.availableQty,
+ },
+ {
+ key: "status",
+ label: t("status"),
+ value: (wl) => tr.lotLineStatus(wl.status),
+ },
+ ]}
+ />
+ )}
+
+ {block.origins.length > 0 && (
+ String(o.stockInLineId)}
+ columns={[
+ {
+ key: "type",
+ label: t("type"),
+ value: (o) => o.type,
+ cell: (o) => (
+
+ ),
+ },
+ {
+ key: "refCode",
+ label: t("refCode"),
+ value: (o) => o.refCode,
+ },
+ {
+ key: "supplier",
+ label: t("supplier"),
+ value: (o) => o.supplierName || o.supplierCode,
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (o) => o.acceptedQty,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (o) => o.receiptDate,
+ },
+ ]}
+ />
+ )}
+
+ {block.qcResults.length > 0 && (
+ `${qc.stockInLineId}-${qc.created}`}
+ columns={[
+ {
+ key: "qcPassed",
+ label: t("qcPassed"),
+ value: (qc) =>
+ qc.qcPassed ? t("qcPassed") : t("qcFailed"),
+ cell: (qc) => (
+
+ ),
+ },
+ {
+ key: "acceptedQty",
+ label: t("acceptedQty"),
+ align: "right",
+ value: (qc) => qc.acceptedQty,
+ },
+ {
+ key: "failQty",
+ label: t("failQty"),
+ align: "right",
+ value: (qc) => qc.failQty,
+ },
+ {
+ key: "qcType",
+ label: t("detailQcType"),
+ value: (qc) => qc.qcType,
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (qc) => qc.handledBy,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (qc) => qc.created,
+ },
+ ]}
+ />
+ )}
+
+ {block.purchaseEvents && block.purchaseEvents.length > 0 && (
+ String(pe.purchaseOrderLineId)}
+ columns={[
+ {
+ key: "po",
+ label: t("detailPurchaseOrderNo"),
+ value: (pe) => pe.purchaseOrderCode,
+ },
+ {
+ key: "supplier",
+ label: t("supplier"),
+ value: (pe) => pe.supplierName || pe.supplierCode,
+ },
+ {
+ key: "orderQty",
+ label: t("detailOrderQty"),
+ align: "right",
+ value: (pe) => pe.orderQty,
+ },
+ {
+ key: "putAwayQty",
+ label: t("detailPutAwayQty"),
+ align: "right",
+ value: (pe) => pe.putAwayQty,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (pe) => pe.orderDate,
+ },
+ ]}
+ />
+ )}
+
+ {block.putawayEvents.length > 0 && (
+ `${pa.inventoryLotLineId}-${idx}`}
+ columns={[
+ {
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (pa) => pa.warehouseCode,
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (pa) => pa.qty,
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (pa) => pa.handledBy,
+ },
+ {
+ key: "refCode",
+ label: t("refCode"),
+ value: (pa) => pa.refCode,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (pa) => pa.timestamp,
+ },
+ ]}
+ />
+ )}
+
+ {block.movements.length > 0 && (
+ `${m.refCode}-${m.timestamp}-${idx}`}
+ columns={[
+ {
+ key: "direction",
+ label: t("direction"),
+ value: (m) => m.direction,
+ cell: (m) => (
+
+ ),
+ },
+ {
+ key: "type",
+ label: t("type"),
+ value: (m) => m.refType,
+ },
+ {
+ key: "refCode",
+ label: t("refCode"),
+ value: (m) => m.refCode,
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (m) => m.qty,
+ },
+ {
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (m) => m.warehouseCode,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (m) => m.timestamp,
+ },
+ ]}
+ />
+ )}
+
+ {block.outboundUsage.length > 0 && (
+ `${o.stockOutLineId}-${idx}`}
+ columns={[
+ {
+ key: "pickOrder",
+ label: t("pickOrder"),
+ value: (o) => o.pickOrderCode || o.deliveryOrderCode,
+ },
+ {
+ key: "type",
+ label: t("type"),
+ value: (o) => tr.usageType(o.usageType),
+ cell: (o) => (
+
+ ),
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (o) => o.qty,
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (o) => o.handler,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (o) => o.timestamp,
+ },
+ ]}
+ />
+ )}
+
+ {block.stockTakeEvents.length > 0 && (
+ `${st.stockTakeCode}-${idx}`}
+ columns={[
+ {
+ key: "code",
+ label: t("stockTakeCode"),
+ value: (st) => st.stockTakeCode,
+ },
+ {
+ key: "section",
+ label: t("section"),
+ value: (st) => st.stockTakeSection,
+ },
+ {
+ key: "round",
+ label: t("round"),
+ value: (st) => st.stockTakeRoundName,
+ },
+ {
+ key: "before",
+ label: t("before"),
+ align: "right",
+ value: (st) =>
+ resolveStockTakeBookQty(st.recordDetail, st.beforeQty),
+ },
+ {
+ key: "after",
+ label: t("after"),
+ align: "right",
+ value: (st) =>
+ resolveStockTakeAcceptedQty(
+ st.recordDetail,
+ st.afterQty,
+ ),
+ },
+ {
+ key: "variance",
+ label: t("variance"),
+ align: "right",
+ value: (st) =>
+ st.recordDetail?.varianceQty != null
+ ? st.recordDetail.varianceQty
+ : st.varianceQty,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (st) => st.timestamp,
+ },
+ ]}
+ />
+ )}
+
+ {block.adjustments.length > 0 && (
+
+ `${adj.refCode}-${adj.timestamp}-${idx}`
+ }
+ columns={[
+ {
+ key: "type",
+ label: t("type"),
+ value: (adj) => adj.adjustmentType,
+ },
+ {
+ key: "direction",
+ label: t("direction"),
+ value: (adj) => adj.direction,
+ cell: (adj) => (
+
+ ),
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (adj) => adj.qty,
+ },
+ {
+ key: "remarks",
+ label: t("remarks"),
+ value: (adj) => adj.reason,
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (adj) => adj.handledBy,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (adj) => adj.timestamp,
+ },
+ ]}
+ />
+ )}
+
+ {block.transfers.length > 0 && (
+ `${xfer.transferCode}-${idx}`}
+ columns={[
+ {
+ key: "from",
+ label: t("from"),
+ value: (xfer) => xfer.fromWarehouse,
+ },
+ {
+ key: "to",
+ label: t("to"),
+ value: (xfer) => xfer.toWarehouse,
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (xfer) => xfer.qty,
+ },
+ {
+ key: "refCode",
+ label: t("refCode"),
+ value: (xfer) => xfer.transferCode,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (xfer) => xfer.timestamp,
+ },
+ ]}
+ />
+ )}
+
+ {block.doDeliveries.length > 0 && (
+ `${dd.stockOutLineId}-${idx}`}
+ columns={[
+ {
+ key: "pickOrder",
+ label: t("pickOrder"),
+ value: (dd) => dd.pickOrderCode,
+ },
+ {
+ key: "deliveryOrder",
+ label: t("deliveryOrder"),
+ value: (dd) => dd.deliveryOrderCode,
+ },
+ {
+ key: "deliveryNoteCode",
+ label: t("deliveryNoteCode"),
+ value: (dd) => dd.deliveryNoteCode || "—",
+ },
+ {
+ key: "ticketNo",
+ label: t("ticketNo"),
+ value: (dd) => dd.ticketNo || "—",
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (dd) => dd.qty,
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (dd) => dd.handler,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (dd) => dd.timestamp,
+ },
+ ]}
+ />
+ )}
+
+ {block.failEvents.length > 0 && (
+ String(fe.failId)}
+ columns={[
+ {
+ key: "category",
+ label: t("detailFailCategory"),
+ value: (fe) => `${fe.failType} / ${fe.category}`,
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (fe) => fe.qty,
+ },
+ {
+ key: "pickOrder",
+ label: t("pickOrder"),
+ value: (fe) => fe.pickOrderCode,
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (fe) => fe.handlerName,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (fe) => fe.recordDate,
+ },
+ ]}
+ />
+ )}
+
+ {block.returnEvents.length > 0 && (
+ `${re.stockOutLineId}-${idx}`}
+ columns={[
+ {
+ key: "type",
+ label: t("type"),
+ value: (re) => re.movementType,
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (re) => re.qty,
+ },
+ {
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (re) => re.warehouseCode,
+ },
+ {
+ key: "remarks",
+ label: t("remarks"),
+ value: (re) => re.remarks,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (re) => re.timestamp,
+ },
+ ]}
+ />
+ )}
+
+ {block.openMovements.length > 0 && (
+ String(om.stockInLineId)}
+ columns={[
+ {
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (om) => om.warehouseCode,
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (om) => om.qty,
+ },
+ {
+ key: "refCode",
+ label: t("refCode"),
+ value: (om) => om.refCode,
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (om) => om.handledBy,
+ },
+ {
+ key: "date",
+ label: t("date"),
+ value: (om) => om.timestamp,
+ },
+ ]}
+ />
+ )}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default ItemTracingLocations;
diff --git a/src/components/ItemTracing/ItemTracingLotTraceLink.tsx b/src/components/ItemTracing/ItemTracingLotTraceLink.tsx
new file mode 100644
index 0000000..840be42
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingLotTraceLink.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import Link from "next/link";
+import MuiLink, { type LinkProps as MuiLinkProps } from "@mui/material/Link";
+import { buildItemTracingHref } from "./traceNavigationUtils";
+
+type Props = {
+ label: string;
+ lotNo?: string;
+ itemCode?: string;
+ stockInLineId?: number;
+ /** Prevent React Flow node selection when the link sits on a graph card. */
+ stopPropagation?: boolean;
+ variant?: MuiLinkProps["variant"];
+ sx?: MuiLinkProps["sx"];
+};
+
+const stopGraphEvent = (e: React.MouseEvent) => {
+ e.stopPropagation();
+};
+
+const ItemTracingLotTraceLink: React.FC = ({
+ label,
+ lotNo,
+ itemCode,
+ stockInLineId,
+ stopPropagation = false,
+ variant = "caption",
+ sx,
+}) => {
+ if (!lotNo?.trim() && stockInLineId == null) return null;
+
+ return (
+
+ {label}
+
+ );
+};
+
+export default ItemTracingLotTraceLink;
diff --git a/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx
new file mode 100644
index 0000000..ed1978e
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import {
+ Box,
+ Chip,
+ Divider,
+ IconButton,
+ List,
+ ListItem,
+ ListItemText,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import { useTranslation } from "react-i18next";
+import { TraceGraphNode } from "./buildTraceGraphNodes";
+import ItemTracingDocLink from "./ItemTracingDocLink";
+import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink";
+import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle";
+import { formatQty } from "./traceQtyUtils";
+import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils";
+
+type Props = {
+ node: TraceGraphNode;
+ onClose?: () => void;
+};
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */
+const ItemTracingNodeDetailPanel: React.FC = ({ node, onClose }) => {
+ const { t } = useTranslation("itemTracing");
+
+ const hasDocLink = node.docLinkKind && node.refCode;
+ const lifecycleStages =
+ node.kind === "STOCK_TAKE" && node.stockTakeRecordDetail
+ ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail)
+ : [];
+ const activeStageCount = lifecycleStages.filter((s) => s.isActive).length;
+
+ return (
+
+
+
+
+
+ {t("nodeDetailTitle")}
+
+ {node.categoryLabel && (
+
+ )}
+
+ {onClose && (
+
+
+
+ )}
+
+
+
+
+
+ {hasDocLink ? (
+
+ ) : (
+ node.title
+ )}
+
+ {node.subtitle && (
+
+ {node.subtitle}
+
+ )}
+
+
+
+ {node.details.map((row, i) => (
+
+
+ {row.label}
+
+
+ {row.variant === "qcCriteriaList" && row.qcCriteriaItems?.length ? (
+
+ {row.qcCriteriaItems.map((item, idx) => (
+
+
+
+ {item.name}
+
+
+ {!item.passed && item.failQty != null && item.failQty > 0 && (
+
+ {t("failQty")}: {formatQty(item.failQty, node.uom)}
+
+ )}
+
+ }
+ secondary={
+ item.description ? (
+
+ {item.description}
+
+ ) : null
+ }
+ />
+
+ ))}
+
+ ) : row.linkKind && row.linkCode ? (
+
+ ) : (
+
+ {row.value || "—"}
+
+ )}
+
+
+ ))}
+
+
+
+ {node.traceLotNo ? (
+
+
+
+ ) : null}
+
+ {lifecycleStages.length > 0 && (
+ <>
+
+
+ {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeStageCount })}
+
+
+ >
+ )}
+
+ {node.meta && (
+ <>
+
+
+ {node.meta}
+
+ >
+ )}
+
+
+ );
+};
+
+export default ItemTracingNodeDetailPanel;
diff --git a/src/components/ItemTracing/ItemTracingScanBar.tsx b/src/components/ItemTracing/ItemTracingScanBar.tsx
new file mode 100644
index 0000000..40739c6
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingScanBar.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import {
+ Alert,
+ Button,
+ Chip,
+ Paper,
+ Stack,
+ TextField,
+ Typography,
+} from "@mui/material";
+import QrCodeScannerIcon from "@mui/icons-material/QrCodeScanner";
+import SearchIcon from "@mui/icons-material/Search";
+import { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider";
+
+type ScanBarProps = {
+ onTrace: (params: {
+ stockInLineId?: number;
+ lotNo?: string;
+ itemCode?: string;
+ }) => void;
+ loading: boolean;
+ lastLotNo?: string;
+};
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 12 | v1.0.0 | 2026-07-17 */
+const ItemTracingScanBar: React.FC = ({
+ onTrace,
+ loading,
+ lastLotNo,
+}) => {
+ const { t } = useTranslation("itemTracing");
+ const scanner = useQrCodeScannerContext();
+ const [scanMode, setScanMode] = useState<"idle" | "wedge">("idle");
+ const [itemCode, setItemCode] = useState("");
+ const [lotNo, setLotNo] = useState("");
+ const [scanError, setScanError] = useState(null);
+
+ const startWedgeScan = useCallback(() => {
+ setScanError(null);
+ setScanMode("wedge");
+ scanner.resetScan();
+ scanner.startScan();
+ }, [scanner]);
+
+ const stopWedgeScan = useCallback(() => {
+ scanner.stopScan();
+ scanner.resetScan();
+ setScanMode("idle");
+ }, [scanner]);
+
+ useEffect(() => {
+ if (scanMode !== "wedge") return;
+ const itemId = scanner.result?.itemId;
+ const stockInLineId = scanner.result?.stockInLineId;
+ if (!itemId || !stockInLineId) return;
+ setScanError(null);
+ stopWedgeScan();
+ onTrace({ stockInLineId: Number(stockInLineId) });
+ }, [scanMode, scanner.result, onTrace, stopWedgeScan]);
+
+ const handleManualSearch = () => {
+ const code = itemCode.trim();
+ const lot = lotNo.trim();
+ if (!code || !lot) return;
+ onTrace({ itemCode: code, lotNo: lot });
+ };
+
+ return (
+
+
+
+ }
+ onClick={scanMode === "wedge" ? stopWedgeScan : startWedgeScan}
+ disabled={loading}
+ >
+ {scanMode === "wedge" ? t("scanning") : t("scanAgain")}
+
+ {lastLotNo && (
+
+ )}
+
+
+ {scanError && (
+ setScanError(null)}>
+ {scanError}
+
+ )}
+
+ {scanMode === "wedge" && (
+
+ {t("scanReady")}
+
+ )}
+
+ {t("manualSearch")}
+
+ setItemCode(e.target.value)}
+ disabled={loading}
+ fullWidth
+ />
+ setLotNo(e.target.value)}
+ disabled={loading}
+ fullWidth
+ />
+ }
+ onClick={handleManualSearch}
+ disabled={loading || !itemCode.trim() || !lotNo.trim()}
+ sx={{ minWidth: 120 }}
+ >
+ {loading ? t("searching") : t("search")}
+
+
+
+
+ );
+};
+
+export default ItemTracingScanBar;
diff --git a/src/components/ItemTracing/ItemTracingSections.tsx b/src/components/ItemTracing/ItemTracingSections.tsx
new file mode 100644
index 0000000..a174648
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingSections.tsx
@@ -0,0 +1,910 @@
+"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. 17 | v1.0.1 | 2026-07-20 */
+const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) => {
+ const { t } = useTranslation("itemTracing");
+ const [tab, setTab] = useState(0);
+ const { bomTrace, joPrelude, lot } = data;
+ const tr = useMemo(() => createTraceLabelTranslator(t), [t]);
+
+ const presentationRows = useMemo(
+ () => buildTracePresentationRows(compiledGraph.nodes, data),
+ [compiledGraph.nodes, data],
+ );
+
+ const multiLocation = hasMultipleLocations(data);
+ const mergedOrigins = useMemo(() => mergeScopedOrigins(data), [data]);
+ const mergedQc = useMemo(() => mergeScopedQcResults(data), [data]);
+ const mergedOutbound = useMemo(() => mergeScopedOutboundUsage(data), [data]);
+ const mergedStockTake = useMemo(() => mergeScopedStockTakeEvents(data), [data]);
+ const mergedAdjustments = useMemo(() => mergeScopedAdjustments(data), [data]);
+ const mergedTransfers = useMemo(() => mergeScopedTransfers(data), [data]);
+
+ const stockUom = lot.uom;
+
+ const tabKeys = useMemo(() => {
+ const keys = [
+ "origins",
+ "qc",
+ "outbound",
+ "stockTake",
+ "adjustments",
+ "transfers",
+ "bom",
+ ] as const;
+ if (joPrelude) {
+ return [...keys, "joPick"] as const;
+ }
+ return keys;
+ }, [joPrelude]);
+
+ const activeTab = tabKeys[tab] ?? tabKeys[0];
+
+ const bomDirectionLabel =
+ bomTrace.direction === "FINISHED_GOOD"
+ ? t("bomDirectionFG")
+ : bomTrace.direction === "MATERIAL"
+ ? t("bomDirectionMaterial")
+ : t("bomDirectionUnknown");
+
+ const useJoMaterialInputs = joPrelude != null && joPrelude.materialInputs.length > 0;
+
+ const checkboxCellFromStatus = (
+ status?: string | null,
+ opts?: { checkedWhen?: Array; rejectedWhen?: Array },
+ ): React.ReactNode => {
+ const s = String(status || "").trim().toLowerCase();
+ const checkedWhen = opts?.checkedWhen ?? [];
+ const rejectedWhen = opts?.rejectedWhen ?? [];
+
+ const isRejected = rejectedWhen.includes(s);
+ const isChecked = checkedWhen.includes(s);
+
+ if (isRejected) {
+ return (
+
+ );
+ }
+ if (isChecked) {
+ return (
+
+ );
+ }
+ return ;
+ };
+
+ const originCols = useMemo((): FilterableColumnDef<(typeof mergedOrigins)[number]>[] => {
+ const cols: FilterableColumnDef<(typeof mergedOrigins)[number]>[] = [];
+ if (multiLocation) {
+ cols.push({
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (o) => o.scopeWarehouseCode,
+ });
+ }
+ cols.push(
+ {
+ key: "type",
+ label: t("type"),
+ value: (o) => tr.refType(o.type),
+ },
+ {
+ key: "ref",
+ label: t("ref"),
+ value: (o) => o.refCode || "—",
+ cell: (o) =>
+ o.type === "PO" ? (
+
+ ) : o.type === "JO" ? (
+
+ ) : (
+ o.refCode || "—"
+ ),
+ },
+ {
+ key: "supplier",
+ label: t("supplier"),
+ value: (o) => [o.supplierCode, o.supplierName].filter(Boolean).join(" "),
+ },
+ { key: "dnNo", label: t("dnNo"), value: (o) => o.dnNo || "—" },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (o) => formatQty(o.acceptedQty, stockUom),
+ },
+ {
+ key: "status",
+ label: t("status"),
+ value: (o) => tr.stockInStatus(o.status),
+ },
+ {
+ key: "timestamp",
+ label: t("timestamp"),
+ value: (o) => o.receiptDate ?? "—",
+ },
+ );
+ return cols;
+ }, [multiLocation, t, tr, stockUom]);
+
+ const qcCols = useMemo((): FilterableColumnDef<(typeof mergedQc)[number]>[] => {
+ const cols: FilterableColumnDef<(typeof mergedQc)[number]>[] = [];
+ if (multiLocation) {
+ cols.push({
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (q) => q.scopeWarehouseCode,
+ });
+ }
+ cols.push(
+ {
+ key: "status",
+ label: t("status"),
+ value: (q) => (q.qcPassed ? t("qcPassed") : t("qcFailed")),
+ cell: (q) => (
+
+ ),
+ },
+ {
+ key: "criteria",
+ label: t("detailQcCriteria"),
+ value: (q) => qcItemLabel(q) || "—",
+ },
+ {
+ key: "acceptedQty",
+ label: t("acceptedQty"),
+ align: "right",
+ value: (q) => formatQty(q.acceptedQty, stockUom),
+ },
+ {
+ key: "failQty",
+ label: t("failQty"),
+ align: "right",
+ value: (q) => formatQty(q.failQty, stockUom),
+ },
+ {
+ key: "remarks",
+ label: t("remarks"),
+ value: (q) => q.remarks?.trim() || "",
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (q) => q.handledBy || "—",
+ },
+ {
+ key: "timestamp",
+ label: t("timestamp"),
+ value: (q) => q.created ?? "—",
+ },
+ );
+ return cols;
+ }, [multiLocation, t, stockUom]);
+
+ const outboundCols = useMemo((): FilterableColumnDef<(typeof mergedOutbound)[number]>[] => {
+ const cols: FilterableColumnDef<(typeof mergedOutbound)[number]>[] = [];
+ if (multiLocation) {
+ cols.push({
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (u) => u.scopeWarehouseCode,
+ });
+ }
+ cols.push(
+ {
+ key: "type",
+ label: t("type"),
+ value: (u) => tr.usageType(u.usageType),
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (u) => formatQty(u.qty, stockUom),
+ },
+ {
+ key: "pickOrder",
+ label: t("pickOrder"),
+ value: (u) => u.pickOrderCode || u.consoCode || "—",
+ cell: (u) => (
+
+ ),
+ },
+ {
+ key: "jobOrder",
+ label: t("jobOrder"),
+ value: (u) => u.jobOrderCode || "—",
+ cell: (u) => (
+
+ ),
+ },
+ {
+ key: "deliveryOrder",
+ label: t("deliveryOrder"),
+ value: (u) => u.deliveryOrderCode || "—",
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (u) => u.handler || "—",
+ },
+ {
+ key: "timestamp",
+ label: t("timestamp"),
+ value: (u) => u.timestamp ?? "—",
+ },
+ );
+ return cols;
+ }, [multiLocation, t, tr, stockUom]);
+
+ const stockTakeCols = useMemo((): FilterableColumnDef<(typeof mergedStockTake)[number]>[] => {
+ const cols: FilterableColumnDef<(typeof mergedStockTake)[number]>[] = [];
+ if (multiLocation) {
+ cols.push({
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (e) => e.scopeWarehouseCode,
+ });
+ }
+ cols.push(
+ { key: "ref", label: t("ref"), value: (e) => e.stockTakeCode },
+ {
+ key: "round",
+ label: t("detailStockTakeRound"),
+ value: (e) =>
+ e.stockTakeRoundName?.trim() ||
+ (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : "—"),
+ },
+ {
+ key: "section",
+ label: t("detailStockTakeSection"),
+ value: (e) => e.stockTakeSection?.trim() || "—",
+ },
+ {
+ key: "location",
+ label: t("detailLocation"),
+ value: (e) => e.warehouseCode?.trim() || "—",
+ },
+ {
+ key: "lotNo",
+ label: t("lotNo"),
+ value: (e) => e.lotNo?.trim() || data.lot.lotNo || "—",
+ },
+ {
+ key: "before",
+ label: t("before"),
+ align: "right",
+ value: (e) =>
+ formatQty(
+ resolveStockTakeBookQty(e.recordDetail, e.beforeQty),
+ stockUom,
+ ),
+ },
+ {
+ key: "after",
+ label: t("after"),
+ align: "right",
+ value: (e) =>
+ formatQty(
+ resolveStockTakeAcceptedQty(e.recordDetail, e.afterQty),
+ stockUom,
+ ),
+ },
+ {
+ key: "variance",
+ label: t("variance"),
+ align: "right",
+ value: (e) =>
+ formatQty(
+ e.recordDetail?.varianceQty != null
+ ? e.recordDetail.varianceQty
+ : e.varianceQty,
+ stockUom,
+ ),
+ },
+ {
+ key: "approver",
+ label: t("approver"),
+ value: (e) => e.approver || "—",
+ },
+ {
+ key: "timestamp",
+ label: t("timestamp"),
+ value: (e) => e.timestamp ?? "—",
+ },
+ );
+ return cols;
+ }, [multiLocation, t, stockUom, data.lot.lotNo]);
+
+ const adjustmentCols = useMemo((): FilterableColumnDef<(typeof mergedAdjustments)[number]>[] => {
+ const cols: FilterableColumnDef<(typeof mergedAdjustments)[number]>[] = [];
+ if (multiLocation) {
+ cols.push({
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (a) => a.scopeWarehouseCode,
+ });
+ }
+ cols.push(
+ {
+ key: "type",
+ label: t("type"),
+ value: (a) => a.adjustmentType,
+ },
+ {
+ key: "direction",
+ label: `${t("directionIn")}/${t("directionOut")}`,
+ value: (a) => a.direction,
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (a) => formatQty(a.qty, stockUom),
+ },
+ {
+ key: "ref",
+ label: t("ref"),
+ value: (a) => a.refCode || "—",
+ },
+ {
+ key: "remarks",
+ label: t("remarks"),
+ value: (a) => a.reason?.trim() || "",
+ },
+ {
+ key: "handler",
+ label: t("handler"),
+ value: (a) => a.handledBy || "—",
+ },
+ {
+ key: "timestamp",
+ label: t("timestamp"),
+ value: (a) => a.timestamp ?? "—",
+ },
+ );
+ return cols;
+ }, [multiLocation, t, stockUom]);
+
+ const transferCols = useMemo((): FilterableColumnDef<(typeof mergedTransfers)[number]>[] => {
+ const cols: FilterableColumnDef<(typeof mergedTransfers)[number]>[] = [];
+ if (multiLocation) {
+ cols.push({
+ key: "warehouse",
+ label: t("warehouse"),
+ value: (row) => row.scopeWarehouseCode,
+ });
+ }
+ cols.push(
+ {
+ key: "from",
+ label: t("from"),
+ value: (row) => row.fromWarehouse || "—",
+ },
+ {
+ key: "to",
+ label: t("to"),
+ value: (row) => row.toWarehouse || "—",
+ },
+ {
+ key: "qty",
+ label: t("qty"),
+ align: "right",
+ value: (row) => formatQty(row.qty, stockUom),
+ },
+ {
+ key: "timestamp",
+ label: t("timestamp"),
+ value: (row) => row.timestamp ?? "—",
+ },
+ );
+ return cols;
+ }, [multiLocation, t, stockUom]);
+
+ type JoMaterialInput = NonNullable["materialInputs"][number];
+ type BomUpstream = (typeof bomTrace.upstream)[number];
+
+ const bomUpstreamJoCols = useMemo((): FilterableColumnDef[] => [
+ {
+ key: "jobOrder",
+ label: t("jobOrder"),
+ value: (m) => m.jobOrderCode || "—",
+ cell: (m) => (
+
+ ),
+ },
+ { key: "material", label: t("itemCode"), value: (m) => m.materialItemCode },
+ { key: "materialLot", label: t("itemLot"), value: (m) => m.materialLotNo },
+ {
+ key: "pickOrder",
+ label: t("pickOrder"),
+ value: (m) => m.pickOrderCode || m.consoCode || "—",
+ cell: (m) =>
+ m.pickOrderCode ? (
+
+ ) : (
+ "—"
+ ),
+ },
+ {
+ key: "pickedAt",
+ label: t("pickedAt"),
+ value: (m) => m.pickedAt ?? "—",
+ },
+ {
+ key: "materialQty",
+ label: t("materialQty"),
+ align: "right",
+ value: (m) => formatNum(Number(m.materialQty)),
+ },
+ {
+ key: "qtyPerUnit",
+ label: t("qtyPerUnit"),
+ align: "right",
+ value: (m) =>
+ m.bomQtyPerUnit != null ? formatNum(Number(m.bomQtyPerUnit)) : "—",
+ },
+ {
+ key: "uom",
+ label: t("uom"),
+ value: (m) => m.materialUom?.trim() || "",
+ },
+ {
+ key: "processingStatus",
+ label: t("processingStatus"),
+ align: "center",
+ value: (m) => tr.processingStatus(m.processingStatus),
+ cell: (m) =>
+ checkboxCellFromStatus(m.processingStatus, {
+ checkedWhen: ["completed"],
+ rejectedWhen: ["rejected"],
+ }),
+ },
+ {
+ key: "matchStatus",
+ label: t("matchStatus"),
+ align: "center",
+ value: (m) => tr.matchStatus(m.matchStatus),
+ cell: (m) => checkboxCellFromStatus(m.matchStatus, { checkedWhen: ["completed"] }),
+ },
+ {
+ key: "trace",
+ label: "",
+ value: () => "",
+ cell: (m) =>
+ m.materialLotNo && onTrace ? (
+
+ onTrace({
+ lotNo: m.materialLotNo,
+ itemCode: m.materialItemCode,
+ })
+ }
+ >
+ {t("traceMaterialLot")}
+
+ ) : (
+ "—"
+ ),
+ },
+ ], [t, tr, stockUom, onTrace]);
+
+ const bomUpstreamCols = useMemo((): FilterableColumnDef[] => [
+ {
+ key: "jobOrder",
+ label: t("jobOrder"),
+ value: (u) => u.jobOrderCode || "—",
+ cell: (u) => (
+
+ ),
+ },
+ { key: "material", label: t("itemCode"), value: (u) => u.materialItemCode },
+ { key: "materialLot", label: t("itemLot"), value: (u) => u.materialLotNo },
+ {
+ key: "materialQty",
+ label: t("materialQty"),
+ align: "right",
+ value: (u) => formatNum(Number(u.materialQty)),
+ },
+ {
+ key: "qtyPerUnit",
+ label: t("qtyPerUnit"),
+ align: "right",
+ value: (u) =>
+ u.bomQtyPerUnit != null ? formatNum(Number(u.bomQtyPerUnit)) : "—",
+ },
+ {
+ key: "uom",
+ label: t("uom"),
+ value: () => stockUom,
+ },
+ ], [t, stockUom]);
+
+ const bomDownstreamCols = useMemo((): FilterableColumnDef<(typeof bomTrace.downstream)[number]>[] => [
+ {
+ key: "jobOrder",
+ label: t("jobOrder"),
+ value: (d) => d.jobOrderCode || "—",
+ cell: (d) => (
+
+ ),
+ },
+ { key: "finishedItem", label: t("itemCode"), value: (d) => d.finishedItemCode },
+ { key: "finishedLot", label: t("itemLot"), 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("itemCode"), value: (r) => r.materialItemCode },
+ {
+ key: "materialName",
+ label: t("detailItemName"),
+ value: (r) => r.materialItemName,
+ },
+ {
+ key: "qtyPerUnit",
+ label: t("qtyPerUnit"),
+ align: "right",
+ value: (r) => formatNum(Number(r.qtyPerUnit)),
+ },
+ { key: "uom", label: t("uom"), value: (r) => r.uom },
+ ], [t]);
+
+ type JoPickRow = (typeof presentationRows.joPicks)[number];
+ const joPickCols = useMemo((): FilterableColumnDef[] => [
+ {
+ key: "pickOrder",
+ label: t("pickOrder"),
+ value: (row) => row.pickOrderCode || row.consoCode || "—",
+ cell: (row) =>
+ row.pickOrderCode ? (
+
+ ) : (
+ "—"
+ ),
+ },
+ { key: "material", label: t("Item"), value: (row) => row.materialItemCode },
+ {
+ key: "lotNo",
+ label: t("lotNo"),
+ value: (row) => row.materialLotNo || "—",
+ cell: (row) =>
+ row.materialLotNo ? (
+
+ ) : (
+ "—"
+ ),
+ },
+ {
+ key: "pickedQty",
+ label: t("pickedQty"),
+ align: "right",
+ value: (row) => formatQty(row.qty, row.uom || stockUom),
+ },
+ {
+ key: "assignedStep",
+ label: t("detailAssignedStep"),
+ value: (row) => row.assignedStepName || "—",
+ },
+ {
+ key: "timestamp",
+ label: t("timestamp"),
+ value: (row) => row.pickedAt ?? "—",
+ },
+ ], [t, stockUom]);
+
+ type JoPickLine = NonNullable["pickOrders"][number]["lines"][number];
+ const joPickLineCols = useMemo((): FilterableColumnDef[] => [
+ {
+ key: "material",
+ label: t("Item"),
+ 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) => tr.pickStatus(line.status) },
+ ], [t, tr, stockUom]);
+
+ return (
+
+ setTab(v)}
+ variant="scrollable"
+ scrollButtons="auto"
+ >
+
+
+
+
+
+
+
+ {joPrelude && }
+
+
+ {multiLocation && (
+
+ {t("sectionsMultiLocationHint")}
+
+ )}
+ {activeTab === "origins" && (
+ `${o.inventoryLotId}-${o.stockInLineId}`}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "qc" && (
+ q.rowKey}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "outbound" && (
+ `${u.inventoryLotId}-${u.stockOutLineId}`}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "stockTake" && (
+ e.rowKey}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "adjustments" && (
+ a.rowKey}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "transfers" && (
+ row.rowKey}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "bom" && (
+
+
+ {t("bomDirection")}: {bomDirectionLabel}
+
+
+
+ {t("bomUpstream")}
+
+
+ {useJoMaterialInputs ? (
+ `up-jo-${i}`}
+ emptyLabel={t("noRecords")}
+ />
+ ) : (
+ `up-${i}`}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+
+
+ {t("bomDownstream")}
+
+
+ `down-${i}`}
+ emptyLabel={t("noRecords")}
+ />
+
+
+
+ {t("bomRecipe")}
+
+ `recipe-${i}`}
+ emptyLabel={t("noRecords")}
+ />
+
+ )}
+
+ {activeTab === "joPick" && joPrelude && (
+
+ {presentationRows.joPicks.length > 0 ? (
+ row.id}
+ emptyLabel={t("noRecords")}
+ />
+ ) : joPrelude.pickOrders.length === 0 ? (
+
+ {t("noRecords")}
+
+ ) : (
+ joPrelude.pickOrders.map((po) => (
+
+
+
+ {" · "}
+
+
+
+ {[
+ po.targetDate && `${t("targetDate")}: ${po.targetDate}`,
+ po.completeDate && `${t("completeDate")}: ${po.completeDate}`,
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+
+ String(line.pickOrderLineId)}
+ emptyLabel={t("noRecords")}
+ />
+
+ ))
+ )}
+
+ )}
+
+
+ );
+};
+
+export default ItemTracingSections;
diff --git a/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx b/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx
new file mode 100644
index 0000000..af3b3aa
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { Box, Typography } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import type { StockTakeLifecycleStage } from "./traceStockTakeUtils";
+import { formatQty } from "./traceQtyUtils";
+
+type Props = {
+ stages: StockTakeLifecycleStage[];
+ uom?: string;
+};
+
+/** Vertical timeline of stock-take progression (first count → approve). */
+const ItemTracingStockTakeLifecycle: React.FC = ({ stages, uom = "" }) => {
+ const { t } = useTranslation("itemTracing");
+
+ if (!stages.length) return null;
+
+ return (
+
+ {stages.map((stage, idx) => {
+ const isLast = idx === stages.length - 1;
+ return (
+
+
+
+ {!isLast && (
+
+ )}
+
+
+
+ {t(stage.label)}
+
+ {stage.isActive ? (
+
+ {[
+ stage.qty != null
+ ? stage.key === "round-created"
+ ? `${t("stockTakeStageBookQty")}: ${formatQty(stage.qty, uom)}`
+ : formatQty(stage.qty, uom)
+ : null,
+ stage.key === "accepted" && stage.badQty != null
+ ? `${t("variance")}: ${formatQty(stage.badQty, uom)}`
+ : stage.badQty != null && stage.badQty > 0
+ ? `${t("unqualifiedQty")}: ${formatQty(stage.badQty, uom)}`
+ : null,
+ stage.handler ?? null,
+ stage.timestamp ?? null,
+ ]
+ .filter(Boolean)
+ .join(" · ") || "—"}
+
+ ) : (
+
+ —
+
+ )}
+
+
+ );
+ })}
+
+ );
+};
+
+export default ItemTracingStockTakeLifecycle;
diff --git a/src/components/ItemTracing/ItemTracingSummary.tsx b/src/components/ItemTracing/ItemTracingSummary.tsx
new file mode 100644
index 0000000..2781126
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingSummary.tsx
@@ -0,0 +1,291 @@
+"use client";
+
+import {
+ Box,
+ Button,
+ Chip,
+ Grid,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { ItemLotTraceResponse } from "@/app/api/itemTracing";
+import ItemTracingDocLink from "./ItemTracingDocLink";
+import { createTraceLabelTranslator } from "./traceLabelUtils";
+import { formatQty } from "./traceQtyUtils";
+import type { WarehouseFocusRequest } from "./ItemTracing";
+
+type Props = {
+ data: ItemLotTraceResponse;
+ onFocusWarehouse?: (request: WarehouseFocusRequest) => void;
+ onExportExcel?: () => void;
+};
+
+type SummaryWarehouseRow = {
+ key: string;
+ inventoryLotId: number;
+ warehouseCode: string;
+ inQty: number;
+ outQty: number;
+ availableQty: number;
+ status: string;
+};
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */
+const ItemTracingSummary: React.FC = ({
+ data,
+ onFocusWarehouse,
+ onExportExcel,
+}) => {
+ const { t } = useTranslation("itemTracing");
+ const tr = useMemo(() => createTraceLabelTranslator(t), [t]);
+ const { lot, warehouseLines, joPrelude, alternateLocations, locationBlocks } =
+ data;
+
+ const combinedWarehouseRows = useMemo((): SummaryWarehouseRow[] => {
+ const primary = warehouseLines.map((w) => ({
+ key: `primary-${w.inventoryLotLineId}`,
+ inventoryLotId: lot.inventoryLotId,
+ warehouseCode: w.warehouseCode,
+ inQty: w.inQty,
+ outQty: w.outQty,
+ availableQty: w.availableQty,
+ status: w.status,
+ }));
+
+ const fromBlocks = (locationBlocks ?? []).flatMap((block) =>
+ block.warehouseLines.map((w) => ({
+ key: `alt-${block.inventoryLotId}-${w.inventoryLotLineId}`,
+ inventoryLotId: block.inventoryLotId,
+ warehouseCode: w.warehouseCode,
+ inQty: w.inQty,
+ outQty: w.outQty,
+ availableQty: w.availableQty,
+ status: w.status,
+ })),
+ );
+
+ if (fromBlocks.length > 0) {
+ return [...primary, ...fromBlocks];
+ }
+
+ // Fallback when location blocks are absent but alternateLocations exist.
+ const fromAlts = alternateLocations.map((loc) => ({
+ key: `alt-loc-${loc.inventoryLotId}-${loc.inventoryLotLineId}`,
+ inventoryLotId: loc.inventoryLotId,
+ warehouseCode: loc.warehouseCode,
+ inQty: 0,
+ outQty: 0,
+ availableQty: loc.availableQty,
+ status: "",
+ }));
+ return [...primary, ...fromAlts];
+ }, [
+ warehouseLines,
+ locationBlocks,
+ alternateLocations,
+ lot.inventoryLotId,
+ ]);
+
+ const totalAvailable = combinedWarehouseRows.reduce(
+ (s, w) => s + (w.availableQty ?? 0),
+ 0,
+ );
+
+ return (
+
+
+
+ {t("summary")}
+
+ {onExportExcel && (
+
+ )}
+
+
+
+
+ {lot.lotNo || "—"}
+
+
+ {lot.itemCode} · {lot.itemName}
+
+
+
+
+
+
+
+ {t("expiryDate")}
+
+ {lot.expiryDate ?? "—"}
+
+
+
+ {t("productionDate")}
+
+ {lot.productionDate ?? "—"}
+
+
+
+ {t("stockInDate")}
+
+ {lot.stockInDate ?? "—"}
+
+
+
+ {t("uom")}
+
+ {lot.uom || "—"}
+
+
+ {joPrelude && (
+
+
+ {t("joContext")}
+
+
+
+
+ {t("jobOrder")}
+
+
+ {joPrelude.jobOrder.jobOrderId != null ? (
+
+ ) : (
+ joPrelude.jobOrder.jobOrderCode || "—"
+ )}
+
+
+
+
+ {t("status")}
+
+
+ {tr.joStatus(joPrelude.jobOrder.status)}
+
+
+
+
+ {t("planStart")}
+
+
+ {joPrelude.jobOrder.planStart ?? "—"}
+
+
+
+
+ {t("plannedQty")}
+
+
+ {formatQty(Number(joPrelude.jobOrder.reqQty), lot.uom)}
+
+
+
+
+ )}
+ {combinedWarehouseRows.length > 0 && (
+
+
+
+
+ {t("warehouse")}
+ {t("inQty")}
+ {t("outQty")}
+ {t("available")}
+ {t("status")}
+ {onFocusWarehouse && (
+ {t("action")}
+ )}
+
+
+
+ {combinedWarehouseRows.map((w) => (
+
+ {w.warehouseCode || "—"}
+
+ {formatQty(w.inQty, lot.uom)}
+
+
+ {formatQty(w.outQty, lot.uom)}
+
+
+ {formatQty(w.availableQty, lot.uom)}
+
+
+ {w.status ? (
+
+ ) : (
+ "—"
+ )}
+
+ {onFocusWarehouse && (
+
+ {w.warehouseCode?.trim() ? (
+
+ ) : (
+ "—"
+ )}
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+export default ItemTracingSummary;
diff --git a/src/components/ItemTracing/TraceFlowEdge.tsx b/src/components/ItemTracing/TraceFlowEdge.tsx
new file mode 100644
index 0000000..dbbfbcd
--- /dev/null
+++ b/src/components/ItemTracing/TraceFlowEdge.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
+import { buildTraceFlowCorridorPath } from "./traceFlowEdgeLayout";
+
+export type TraceFlowEdgeData = {
+ offset?: number;
+ pathMode?: "smooth" | "corridor";
+ corridorY?: number;
+ corridorEntryOffset?: number;
+ corridorExitOffset?: number;
+ corridorBranchOffset?: number;
+};
+
+export const TraceFlowEdge = ({
+ id,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ style,
+ markerEnd,
+ data,
+}: EdgeProps) => {
+ const edgeData = data as TraceFlowEdgeData | undefined;
+ const offset = edgeData?.offset ?? 0;
+
+ const path =
+ edgeData?.pathMode === "corridor" && edgeData.corridorY != null
+ ? buildTraceFlowCorridorPath(
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ edgeData.corridorY,
+ edgeData.corridorEntryOffset,
+ edgeData.corridorExitOffset,
+ edgeData.corridorBranchOffset,
+ )
+ : getSmoothStepPath({
+ sourceX,
+ sourceY,
+ sourcePosition,
+ targetX,
+ targetY,
+ targetPosition,
+ borderRadius: 8,
+ offset,
+ })[0];
+
+ return ;
+};
+
+export const traceFlowEdgeTypes = {
+ traceFlow: TraceFlowEdge,
+};
diff --git a/src/components/ItemTracing/TraceFlowNodes.tsx b/src/components/ItemTracing/TraceFlowNodes.tsx
new file mode 100644
index 0000000..8a116c7
--- /dev/null
+++ b/src/components/ItemTracing/TraceFlowNodes.tsx
@@ -0,0 +1,878 @@
+"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, resolveDoOutboundQtyColor } from "./traceLabelUtils";
+
+const PHASE_LABEL_INNER = 96;
+/** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */
+const STOCK_TAKE_LIFECYCLE_PANEL_EXTRA = 320;
+
+const GroupCollapseButton = ({
+ collapsed,
+ onToggle,
+ collapseLabel,
+ expandLabel,
+}: {
+ collapsed: boolean;
+ onToggle?: () => void;
+ collapseLabel: string;
+ expandLabel: string;
+}) => (
+ {
+ e.stopPropagation();
+ onToggle?.();
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ sx={{
+ pointerEvents: "all",
+ p: 0.25,
+ ml: "auto",
+ flexShrink: 0,
+ color: "warning.dark",
+ }}
+ >
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+);
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 */
+export const TraceFlowEventNode = memo(function TraceFlowEventNode({
+ id,
+ data,
+}: NodeProps>) {
+ const { t } = useTranslation("itemTracing");
+ const { setNodes } = useReactFlow();
+ const updateNodeInternals = useUpdateNodeInternals();
+ const node = data.layoutNode;
+ const color = kindColor(node.kind);
+ const isQc = node.kind === "QC" || node.kind === "MATERIAL_QC";
+ const isStockTake = node.kind === "STOCK_TAKE";
+ const hasLifecycle = isStockTake && node.stockTakeRecordDetail != null;
+ const [lifecycleExpanded, setLifecycleExpanded] = useState(false);
+ const lifecycleStages = hasLifecycle
+ ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail)
+ : [];
+ const nodeWidth = data.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH;
+
+ useEffect(() => {
+ if (!hasLifecycle) return;
+ const nextH = lifecycleExpanded
+ ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA
+ : NODE_HEIGHT;
+ setNodes((nds) =>
+ nds.map((n) => {
+ if (n.id !== id) return n;
+ return {
+ ...n,
+ height: nextH,
+ style: { ...(n.style ?? {}), width: nodeWidth, height: nextH },
+ measured: {
+ width: n.measured?.width ?? n.width ?? nodeWidth,
+ height: nextH,
+ },
+ };
+ }),
+ );
+ const raf = requestAnimationFrame(() => updateNodeInternals(id));
+ return () => cancelAnimationFrame(raf);
+ }, [
+ hasLifecycle,
+ lifecycleExpanded,
+ id,
+ nodeWidth,
+ setNodes,
+ updateNodeInternals,
+ ]);
+
+ const chipLabel =
+ node.kind === "PUTAWAY" && node.putawayStatusLabel?.trim()
+ ? node.putawayStatusLabel
+ : isQc && node.qcTypeLabel?.trim()
+ ? node.qcTypeLabel
+ : t(kindLabelKey(node.kind));
+ const dateLabel = node.timestamp?.trim() ? node.timestamp : t("noTimestamp");
+ const isDoOut = node.kind === "DO_OUT";
+ const doOutKindChipSx = {
+ height: data.compact ? 18 : 20,
+ "& .MuiChip-label": {
+ px: 0.75,
+ fontSize: data.compact ? "0.65rem" : "0.7rem",
+ fontWeight: 600,
+ },
+ } as const;
+ const stopCardClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ };
+ const doOutboundKindChips =
+ isDoOut && (node.doOutboundIsExtra || node.doOutboundIsReplenish) ? (
+ <>
+ {node.doOutboundIsExtra ? (
+
+ ) : null}
+ {node.doOutboundIsReplenish ? (
+
+ ) : null}
+ >
+ ) : null;
+ const titleContent = isDoOut ? (
+
+ {node.docLinkKind && node.refCode ? (
+
+ ) : (
+ node.refCode || "—"
+ )}
+
+ ) : node.docLinkKind && node.refCode ? (
+
+ ) : (
+ node.title
+ );
+ const incomingCount = data.incomingHandleCount ?? 0;
+ const outgoingCount = data.outgoingHandleCount ?? 0;
+ const searchActive = data.searchActive ?? false;
+ const searchMatch = data.searchMatch ?? false;
+ const searchFocused = data.searchFocused ?? false;
+ const nodeSelected = data.nodeSelected ?? false;
+
+ const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };
+
+ const activeCount = lifecycleStages.filter((s) => s.isActive).length;
+
+ const card = (
+
+ {incomingCount > 0 &&
+ Array.from({ length: incomingCount }, (_, i) => (
+
+ ))}
+
+
+
+ {isQc && (
+
+ )}
+
+ {doOutboundKindChips}
+
+
+ {titleContent}
+
+ {node.subtitle?.trim() ? (
+
+ {node.subtitle}
+
+ ) : null}
+ {node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? (
+
+ {t("Item")}: {node.meta?.trim() || node.traceItemCode}
+
+ ) : null}
+ {(node.qty != null || node.kind === "STOCK_TAKE") && (
+
+ {node.kind === "PURCHASE"
+ ? t("detailOrderQty")
+ : node.kind === "STOCK_TAKE"
+ ? t("after")
+ : node.kind === "ADJUSTMENT"
+ ? t("variance")
+ : node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL"
+ ? t("unqualifiedQty")
+ : node.kind === "PRODUCTION_STEP"
+ ? t("processOutputQty")
+ : t("qty")}
+ :{" "}
+ {node.kind === "ADJUSTMENT"
+ ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom)
+ : formatQty(node.qty, node.uom)}
+
+ )}
+ {node.kind === "STOCK_TAKE" ? (
+ 0
+ ? "success.main"
+ : node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0
+ ? "error.main"
+ : undefined,
+ }}
+ >
+ {t("variance")}: {formatQty(node.stockTakeVarianceQty, node.uom)}
+
+ ) : null}
+ {node.traceLotNo ? (
+
+ ) : null}
+ {node.warehouseCode?.trim() ? (
+
+ {t("warehouse")}: {node.warehouseCode}
+
+ ) : null}
+ {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
+ node.processingStatusLabel?.trim() ? (
+
+ {t("processingStatus")}:{" "}
+ {
+ const c = pickStatusValueColor(node.processingStatus);
+ return c === "default" ? "text.primary" : `${c}.main`;
+ })(),
+ fontWeight: 700,
+ }}
+ >
+ {node.processingStatusLabel}
+
+
+ ) : null}
+ {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
+ node.matchStatusLabel?.trim() ? (
+
+ {t("matchStatus")}:{" "}
+ {
+ const c = pickStatusValueColor(node.matchStatus);
+ return c === "default" ? "text.primary" : `${c}.main`;
+ })(),
+ fontWeight: 700,
+ }}
+ >
+ {node.matchStatusLabel}
+
+
+ ) : null}
+
+ {dateLabel}
+
+
+ {hasLifecycle ? (
+
+ {
+ e.stopPropagation();
+ setLifecycleExpanded((v) => !v);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ setLifecycleExpanded((v) => !v);
+ }
+ }}
+ sx={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 0.5,
+ width: "100%",
+ py: 0.25,
+ px: 0.5,
+ borderRadius: 1,
+ cursor: "pointer",
+ userSelect: "none",
+ "&:hover": { bgcolor: "action.hover" },
+ }}
+ >
+
+ {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })}
+
+ {lifecycleExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ ) : null}
+ {outgoingCount > 0 &&
+ Array.from({ length: outgoingCount }, (_, i) => (
+
+ ))}
+
+ );
+
+ // Inline under the card so pan/zoom keeps the panel attached (Popper portals to body).
+ const lifecyclePanel =
+ hasLifecycle && lifecycleExpanded ? (
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onWheel={(e) => e.stopPropagation()}
+ sx={{
+ width: nodeWidth,
+ maxWidth: nodeWidth,
+ mt: 0.75,
+ p: 1.25,
+ boxShadow: 4,
+ borderColor: "secondary.main",
+ borderWidth: 1.5,
+ maxHeight: STOCK_TAKE_LIFECYCLE_PANEL_EXTRA - 24,
+ overflow: "auto",
+ overscrollBehavior: "contain",
+ bgcolor: "background.paper",
+ pointerEvents: "all",
+ }}
+ >
+
+ {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })}
+
+
+
+
+ ) : null;
+
+ const wrapped = (
+
+ {card}
+ {lifecyclePanel}
+
+ );
+
+ if (node.meta) {
+ return (
+
+ {wrapped}
+
+ );
+ }
+ return wrapped;
+});
+
+export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({
+ data,
+}: NodeProps>) {
+ return (
+
+
+ {data.phaseLabel}
+
+
+ );
+});
+
+export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({
+ data,
+}: NodeProps>) {
+ return (
+
+ {data.dateLabel}
+
+ );
+});
+
+export const TraceFlowDoGroupNode = memo(function TraceFlowDoGroupNode({
+ data,
+}: NodeProps>) {
+ const { t } = useTranslation("itemTracing");
+ const node = data.layoutNode;
+ const incomingCount = data.incomingHandleCount ?? 0;
+ const searchActive = data.searchActive ?? false;
+ const searchMatch = data.searchMatch ?? false;
+ const searchFocused = data.searchFocused ?? false;
+ const collapsed = data.groupCollapsed === true;
+ const width = node.groupBoxWidth ?? NODE_WIDTH;
+ const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT);
+ const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };
+
+ return (
+ alpha(theme.palette.background.paper, 0.45),
+ opacity: searchActive && !searchMatch ? 0.35 : 1,
+ boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined,
+ position: "relative",
+ overflow: "hidden",
+ pointerEvents: "none",
+ }}
+ >
+ {incomingCount > 0 &&
+ Array.from({ length: incomingCount }, (_, i) => (
+
+ ))}
+ alpha(theme.palette.warning.light, 0.35),
+ borderBottom: collapsed ? 0 : 1,
+ borderColor: "warning.light",
+ }}
+ >
+
+ {node.title}
+
+ {node.groupTotalQty != null ? (
+
+ {t("flowDoGroupTotalQty", {
+ qtyLabel: formatQty(node.groupTotalQty, node.groupUom),
+ })}
+
+ ) : null}
+
+
+
+ );
+});
+
+export const TraceFlowPickGroupNode = memo(function TraceFlowPickGroupNode({
+ data,
+}: NodeProps>) {
+ const { t } = useTranslation("itemTracing");
+ const node = data.layoutNode;
+ const incomingCount = data.incomingHandleCount ?? 0;
+ const outgoingCount = data.outgoingHandleCount ?? 0;
+ const searchActive = data.searchActive ?? false;
+ const searchMatch = data.searchMatch ?? false;
+ const searchFocused = data.searchFocused ?? false;
+ const collapsed = data.groupCollapsed === true;
+ const width = node.groupBoxWidth ?? NODE_WIDTH;
+ const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT);
+ const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };
+
+ return (
+ alpha(theme.palette.background.paper, 0.45),
+ opacity: searchActive && !searchMatch ? 0.35 : 1,
+ boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined,
+ position: "relative",
+ overflow: "hidden",
+ pointerEvents: "none",
+ }}
+ >
+ {incomingCount > 0 &&
+ Array.from({ length: incomingCount }, (_, i) => (
+
+ ))}
+ alpha(theme.palette.warning.light, 0.35),
+ borderBottom: collapsed ? 0 : 1,
+ borderColor: "warning.light",
+ }}
+ >
+
+ {node.title}
+
+
+
+ {outgoingCount > 0 &&
+ Array.from({ length: outgoingCount }, (_, i) => (
+
+ ))}
+
+ );
+});
+
+export const traceFlowNodeTypes = {
+ traceEvent: TraceFlowEventNode,
+ doGroup: TraceFlowDoGroupNode,
+ pickGroup: TraceFlowPickGroupNode,
+ phaseLabel: TraceFlowPhaseLabelNode,
+ dateHeader: TraceFlowDateHeaderNode,
+};
diff --git a/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts b/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts
new file mode 100644
index 0000000..7811206
--- /dev/null
+++ b/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts
@@ -0,0 +1,278 @@
+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,
+ doOutboundQtyChanged: d.qtyChanged === true,
+ outboundKindLabel,
+ warehouseCode: scope?.defaultWarehouseCode,
+ details: [
+ field(labels.detailType, labels.nodeDoOut),
+ ...(outboundKindLabel
+ ? [field(labels.detailDoOutboundKind, outboundKindLabel)]
+ : []),
+ field(labels.deliveryOrder, d.deliveryOrderCode),
+ ...(d.deliveryNoteCode?.trim()
+ ? [field(labels.deliveryNoteCode, d.deliveryNoteCode.trim())]
+ : []),
+ ...(d.ticketNo?.trim()
+ ? [field(labels.ticketNo, d.ticketNo.trim())]
+ : []),
+ field(labels.detailSupplyTo, supplyTo || "—"),
+ field(labels.pickOrder, d.pickOrderCode, {
+ linkKind: docLink.kind,
+ linkCode: d.pickOrderCode,
+ linkId: d.pickOrderId,
+ consoCode: docLink.consoCode || d.consoCode || d.pickOrderCode,
+ linkTicketNo: docLink.ticketNo,
+ linkTargetDate: docLink.targetDate,
+ }),
+ field(labels.detailQty, formatQty(d.qty, stockUom)),
+ field(labels.detailHandler, d.handler),
+ field(labels.detailTime, d.timestamp),
+ ],
+ });
+ });
+
+ (data.returnEvents ?? []).forEach((r, i) => {
+ nodes.push({
+ id: lb
+ ? `${idPfx}return-${i}-${r.stockOutLineId}`
+ : `${idPfx}return-${r.stockOutLineId}-${r.timestamp}`,
+ kind: "RETURN",
+ timestamp: r.timestamp,
+ sortKey: parseSortKey(r.timestamp, seq++),
+ title: labels.nodeReturn,
+ subtitle: [labels.tr.movementType(r.movementType), r.refCode].filter(Boolean).join(" · ") || "—",
+ qty: r.qty,
+ uom: stockUom,
+ refCode: r.refCode,
+ categoryLabel: labels.categoryOutbound,
+ details: detailsOf(
+ field(labels.detailType, labels.tr.movementType(r.movementType)),
+ field(labels.detailReturnRef, r.refCode),
+ field(labels.detailWarehouse, r.warehouseCode),
+ field(labels.detailQty, formatQty(r.qty, stockUom)),
+ field(labels.detailHandler, r.handler),
+ field(labels.detailTime, r.timestamp),
+ fieldIf(labels.detailRemarks, r.remarks),
+ ),
+ });
+ });
+
+ (data.lotRelations ?? []).forEach((rel, i) => {
+ nodes.push({
+ id: `repack-${rel.inventoryLotId ?? i}-${rel.lotNo}`,
+ kind: "REPACK",
+ timestamp: rel.timestamp,
+ sortKey: parseSortKey(rel.timestamp, seq++),
+ title: `${rel.itemCode} · ${labels.nodeRepack}`,
+ subtitle: [rel.lotNo, rel.productLotNo].filter(Boolean).join(" · ") || rel.itemName,
+ qty: rel.qty,
+ uom: stockUom,
+ traceLotNo: rel.lotNo,
+ traceItemCode: rel.itemCode,
+ categoryLabel: labels.categoryTransfer,
+ details: [
+ field(labels.detailType, labels.nodeRepack),
+ field(labels.detailMaterial, `${rel.itemCode} · ${rel.itemName}`),
+ field(labels.detailLot, rel.lotNo),
+ field(labels.detailQty, formatQty(rel.qty, stockUom)),
+ field(labels.detailProductLotNo, rel.productLotNo),
+ field(labels.detailTime, rel.timestamp),
+ ],
+ });
+ });
+
+ return nodes.map((n) => withNodeScope(n, scope));
+};
diff --git a/src/components/ItemTracing/buildJoPreludeGraphNodes.ts b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts
new file mode 100644
index 0000000..537e667
--- /dev/null
+++ b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts
@@ -0,0 +1,632 @@
+import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
+import {
+ TraceGraphDetailField,
+ TraceGraphDetailLabels,
+ TraceGraphNode,
+} from "./buildTraceGraphNodes";
+import {
+ buildProductionGraphNodes,
+ ProductionGraphLabels,
+} from "./buildProductionGraphNodes";
+import {
+ groupQcResultsBySession,
+} from "./traceLabelUtils";
+import { formatQty, formatSignedQty } from "./traceQtyUtils";
+import {
+ createMaterialPickNode,
+ docLinkFromOriginType,
+ docLinkFromRefType,
+ field,
+ fieldIf,
+ detailsOf,
+ isJoProducedMaterial,
+ parseSortKey,
+} from "./traceNodeFactory";
+import { buildPickOrderTargetDateMapFromPrelude } from "./traceDocLinkUtils";
+import { resolvePutawayPresentation, isTransferInboundPutaway } from "./tracePutawayUtils";
+
+export interface JoPreludeGraphLabels extends TraceGraphDetailLabels {
+ nodeMaterialIn: string;
+ nodeMaterialPick: string;
+ nodePurchase: string;
+ nodeReceipt: string;
+ nodeJoCreated: string;
+ pickOrder: string;
+}
+
+type LotPickRef = {
+ pickOrderCode: string;
+ pickOrderId: number | null;
+ consoCode: string;
+};
+
+type PreludeBuildContext = {
+ seenPurchaseLines: Set;
+ seenInboundLots: Set;
+ seenReceiptLots: Set;
+ seenQcKeys: Set;
+ seenPutawayKeys: Set;
+ pickOrderTargetDateMap: Map;
+ seq: number;
+};
+
+const materialLotKey = (itemCode: string, lotNo: string) => `${itemCode}::${lotNo}`;
+
+const mergeLotPickMaps = (
+ target: Map,
+ source: Map,
+): void => {
+ source.forEach((picks, key) => {
+ const list = target.get(key) ?? [];
+ picks.forEach((p) => {
+ if (!list.some((existing) => existing.pickOrderCode === p.pickOrderCode)) {
+ list.push(p);
+ }
+ });
+ if (list.length) target.set(key, list);
+ });
+};
+
+const buildLotPickOrderMap = (
+ materialInputs: ItemLotTraceJoPrelude["materialInputs"],
+): Map => {
+ const map = new Map();
+ materialInputs.forEach((m) => {
+ const code = m.pickOrderCode?.trim();
+ if (!code) return;
+ const key = materialLotKey(m.materialItemCode, m.materialLotNo);
+ const list = map.get(key) ?? [];
+ if (!list.some((p) => p.pickOrderCode === code)) {
+ list.push({
+ pickOrderCode: code,
+ pickOrderId: m.pickOrderId,
+ consoCode: m.consoCode,
+ });
+ }
+ map.set(key, list);
+ const nestedInputs = m.nestedJoPrelude?.materialInputs ?? [];
+ if (nestedInputs.length) {
+ mergeLotPickMaps(map, buildLotPickOrderMapRecursive(nestedInputs));
+ }
+ });
+ return map;
+};
+
+const buildLotPickOrderMapRecursive = (
+ materialInputs: ItemLotTraceMaterialInput[],
+): Map => buildLotPickOrderMap(materialInputs);
+
+const pickOrdersForLot = (
+ map: Map,
+ itemCode: string,
+ lotNo: string,
+): LotPickRef[] => map.get(materialLotKey(itemCode, lotNo)) ?? [];
+
+const pickOrderCodesLabel = (picks: LotPickRef[]) =>
+ picks.map((p) => p.pickOrderCode).join(" · ");
+
+const appendPickToSubtitle = (base: string, picks: LotPickRef[], pickLabel: string): string => {
+ if (!picks.length) return base;
+ const suffix = `${pickLabel}: ${pickOrderCodesLabel(picks)}`;
+ return base ? `${base} · ${suffix}` : suffix;
+};
+
+const pickOrderDetailFields = (
+ picks: LotPickRef[],
+ pickLabel: string,
+): TraceGraphDetailField[] =>
+ picks.map((p) =>
+ field(pickLabel, p.pickOrderCode, {
+ linkKind: "pick",
+ linkCode: p.pickOrderCode,
+ linkId: p.pickOrderId,
+ consoCode: p.consoCode || p.pickOrderCode,
+ }),
+ );
+
+const nextSeq = (ctx: PreludeBuildContext): number => ctx.seq++;
+
+const pickCtx = (ctx: PreludeBuildContext) => ({
+ nextSeq: () => nextSeq(ctx),
+ pickOrderTargetDate: (pickOrderCode: string) =>
+ ctx.pickOrderTargetDateMap.get(pickOrderCode?.trim() ?? ""),
+});
+
+const buildMaterialProductionNodes = (
+ m: ItemLotTraceMaterialInput,
+ labels: JoPreludeGraphLabels & Partial,
+ ctx: PreludeBuildContext,
+): TraceGraphNode[] => {
+ const steps = m.productionSteps ?? [];
+ if (!steps.length || !labels.nodeProductionStep || !labels.nodeScrap || !labels.nodeDefect) return [];
+
+ const matUom = m.materialUom?.trim() || "";
+ return buildProductionGraphNodes(steps, [], labels as ProductionGraphLabels, matUom).map((node) => ({
+ ...node,
+ id: `mat-prod-${m.materialLotNo}-${node.id}`,
+ traceLotNo: m.materialLotNo,
+ traceItemCode: m.materialItemCode,
+ subtitle: [m.materialItemCode, m.materialLotNo, node.subtitle].filter(Boolean).join(" · "),
+ }));
+};
+
+const buildMaterialStockInPreludeNodes = (
+ m: ItemLotTraceMaterialInput,
+ labels: JoPreludeGraphLabels,
+ lotPicks: Map,
+ ctx: PreludeBuildContext,
+): TraceGraphNode[] => {
+ const nodes: TraceGraphNode[] = [];
+ const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo);
+ const lotId = m.materialInventoryLotId;
+ const origin = m.stockInOrigin;
+ const matUom = m.materialUom?.trim() || "";
+
+ (m.purchaseEvents ?? []).forEach((p) => {
+ if (ctx.seenPurchaseLines.has(p.purchaseOrderLineId)) return;
+ ctx.seenPurchaseLines.add(p.purchaseOrderLineId);
+ const purchaseUnit = p.purchaseUnit;
+ const supplierLabel = [p.supplierCode, p.supplierName].filter(Boolean).join(" ");
+ const subtitle =
+ [supplierLabel, p.itemCode, p.itemName].filter(Boolean).join(" · ") || m.materialItemCode;
+ const meta = [supplierLabel].filter(Boolean).join(" · ");
+ nodes.push({
+ id: `mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}`,
+ kind: "PURCHASE",
+ timestamp: p.orderDate,
+ sortKey: parseSortKey(p.orderDate, nextSeq(ctx)),
+ title: labels.nodePurchase,
+ subtitle: appendPickToSubtitle(subtitle, picks, labels.pickOrder),
+ qty: p.orderQty,
+ uom: purchaseUnit,
+ meta: meta || undefined,
+ refType: "PO",
+ refCode: p.purchaseOrderCode,
+ refId: p.purchaseOrderId,
+ docLinkKind: "po",
+ traceLotNo: m.materialLotNo,
+ traceItemCode: m.materialItemCode,
+ categoryLabel: labels.categoryPurchase,
+ details: [
+ field(labels.detailItemCode, p.itemCode),
+ field(labels.detailItemName, p.itemName),
+ field(labels.detailSupplier, supplierLabel),
+ field(labels.detailOrderQty, formatQty(p.orderQty, purchaseUnit)),
+ field(labels.detailPurchaseUnit, p.purchaseUnit),
+ field(labels.detailPurchaseOrderNo, p.purchaseOrderCode, {
+ linkKind: "po",
+ linkCode: p.purchaseOrderCode,
+ linkId: p.purchaseOrderId,
+ }),
+ field(labels.detailTime, p.orderDate),
+ field(labels.detailLot, m.materialLotNo),
+ ...pickOrderDetailFields(picks, labels.pickOrder),
+ ],
+ });
+ });
+
+ if (origin && lotId != null) {
+ const originType = origin.type.trim().toUpperCase();
+ const linkKind = docLinkFromOriginType(origin.type);
+ const subtitle = [
+ m.materialLotNo,
+ [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "),
+ ]
+ .filter(Boolean)
+ .join(" · ");
+
+ if (originType === "PO" && !ctx.seenReceiptLots.has(lotId)) {
+ ctx.seenReceiptLots.add(lotId);
+ nodes.push({
+ id: `mat-receipt-${lotId}-${origin.stockInLineId}`,
+ kind: "RECEIPT",
+ timestamp: origin.receiptDate,
+ sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)),
+ title: labels.nodeReceipt,
+ subtitle: appendPickToSubtitle(
+ [origin.refCode, subtitle].filter(Boolean).join(" · ") || m.materialItemCode,
+ picks,
+ labels.pickOrder,
+ ),
+ qty: origin.acceptedQty,
+ uom: matUom,
+ refType: origin.type,
+ refCode: origin.refCode,
+ refId: origin.refId,
+ docLinkKind: linkKind,
+ traceLotNo: m.materialLotNo,
+ traceItemCode: m.materialItemCode,
+ categoryLabel: labels.categoryReceipt,
+ details: [
+ field(labels.detailPurchaseOrderNo, origin.refCode, {
+ linkKind,
+ linkCode: origin.refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")),
+ field(labels.detailItemCode, m.materialItemCode),
+ field(labels.detailLot, m.materialLotNo),
+ field(labels.detailQty, formatQty(origin.acceptedQty, matUom)),
+ field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
+ field(labels.detailTime, origin.receiptDate),
+ ...pickOrderDetailFields(picks, labels.pickOrder),
+ ],
+ });
+ } else if (
+ originType !== "PO" &&
+ originType !== "ADJ" &&
+ !ctx.seenInboundLots.has(lotId)
+ ) {
+ // Nested JO_CREATED already represents this JO output lot (with 提料 links).
+ const skipJoOriginBecauseNested =
+ originType === "JO" && Boolean(m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim());
+ if (!skipJoOriginBecauseNested) {
+ // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整).
+ ctx.seenInboundLots.add(lotId);
+ const isJoOrigin = originType === "JO";
+ nodes.push({
+ id: `mat-in-${lotId}-${origin.stockInLineId}`,
+ kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN",
+ timestamp: origin.receiptDate,
+ sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)),
+ title: isJoOrigin
+ ? labels.nodeJoCreated
+ : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`,
+ subtitle: appendPickToSubtitle(
+ subtitle || m.materialItemCode,
+ picks,
+ labels.pickOrder,
+ ),
+ qty: origin.acceptedQty,
+ uom: matUom,
+ refType: origin.type,
+ refCode: origin.refCode,
+ refId: origin.refId,
+ docLinkKind: linkKind,
+ traceLotNo: m.materialLotNo,
+ traceItemCode: m.materialItemCode,
+ categoryLabel: isJoOrigin ? labels.categoryProduction : labels.categoryPurchase,
+ details: [
+ field(labels.detailType, isJoOrigin ? labels.nodeJoCreated : labels.tr.refType(origin.type)),
+ field(
+ isJoOrigin
+ ? labels.jobOrder
+ : originType === "PO"
+ ? labels.detailPurchaseOrderNo
+ : labels.detailSourceDoc,
+ origin.refCode,
+ {
+ linkKind,
+ linkCode: origin.refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")),
+ field(labels.detailItemCode, m.materialItemCode),
+ field(labels.detailLot, m.materialLotNo),
+ field(labels.detailQty, formatQty(origin.acceptedQty, matUom)),
+ field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
+ field(labels.detailTime, origin.receiptDate),
+ ...pickOrderDetailFields(picks, labels.pickOrder),
+ ],
+ });
+ }
+ }
+ }
+
+ groupQcResultsBySession(m.qcResults).forEach((group) => {
+ const first = group[0];
+ if (!first) return;
+ const key = `${first.stockInLineId}-${first.created}`;
+ if (ctx.seenQcKeys.has(key)) return;
+ ctx.seenQcKeys.add(key);
+ const allPassed = group.every((q) => q.qcPassed);
+ const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0);
+ const meta = [first.handledBy, ...group.map((q) => q.remarks).filter(Boolean), m.materialLotNo]
+ .filter(Boolean)
+ .join(" · ");
+ const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? "";
+ nodes.push({
+ id: `mat-qc-${key}`,
+ kind: "MATERIAL_QC",
+ timestamp: first.created,
+ sortKey: parseSortKey(first.created, nextSeq(ctx)),
+ title: allPassed ? labels.nodeQcPass : labels.nodeQcFail,
+ subtitle: "",
+ qty: totalFail,
+ uom: matUom,
+ meta: meta || undefined,
+ traceLotNo: m.materialLotNo,
+ traceItemCode: m.materialItemCode,
+ categoryLabel: labels.categoryQc,
+ qcTypeLabel: labels.tr.qcType(qcTypeRaw),
+ details: detailsOf(
+ field(labels.detailItemCode, m.materialItemCode),
+ field(labels.detailLot, m.materialLotNo),
+ field(labels.detailStatus, labels.tr.qcPassed(allPassed)),
+ field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)),
+ field(labels.detailQcCriteria, "", {
+ variant: "qcCriteriaList",
+ qcCriteriaItems: group.map((q) => ({
+ name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem,
+ description: q.qcItemDescription?.trim() || undefined,
+ passed: q.qcPassed,
+ failQty: q.failQty > 0 ? q.failQty : undefined,
+ })),
+ }),
+ field(labels.detailAcceptedQty, formatQty(first.acceptedQty, matUom)),
+ field(labels.detailFailQty, formatQty(totalFail, matUom)),
+ field(labels.detailHandler, first.handledBy),
+ field(labels.detailTime, first.created),
+ fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")),
+ ...pickOrderDetailFields(picks, labels.pickOrder),
+ ),
+ });
+ });
+
+ let lastMaterialPutawayWh = "";
+ (m.putawayEvents ?? []).forEach((p) => {
+ const key = `${p.inventoryLotLineId}-${p.timestamp}`;
+ if (ctx.seenPutawayKeys.has(key)) return;
+ if (isTransferInboundPutaway(p.refType)) return;
+ ctx.seenPutawayKeys.add(key);
+ const linkKind = docLinkFromRefType(p.refType);
+ const pres = resolvePutawayPresentation(
+ labels.tr,
+ {
+ nodePutaway: labels.nodePutaway,
+ nodePutawayTransfer: labels.nodePutawayTransfer,
+ putawayTransferDetail: labels.putawayTransferDetail,
+ },
+ p.status,
+ p.refType,
+ );
+ const meta = [pres.chipLabel, p.handledBy, p.warehouseCode].filter(Boolean).join(" · ");
+ if (p.warehouseCode?.trim()) lastMaterialPutawayWh = p.warehouseCode.trim();
+ nodes.push({
+ id: `mat-putaway-${p.inventoryLotLineId}-${p.timestamp}`,
+ kind: "PUTAWAY",
+ timestamp: p.timestamp,
+ sortKey: parseSortKey(p.timestamp, nextSeq(ctx)),
+ title: pres.title,
+ subtitle: appendPickToSubtitle(
+ [pres.chipLabel, p.refCode, p.warehouseCode, m.materialLotNo].filter(Boolean).join(" · ") ||
+ m.materialItemCode,
+ picks,
+ labels.pickOrder,
+ ),
+ qty: p.qty,
+ uom: matUom,
+ meta: meta || undefined,
+ refType: p.refType,
+ refCode: p.refCode,
+ refId: p.refId,
+ docLinkKind: linkKind,
+ putawayStatusLabel: pres.chipLabel,
+ putawayStatusDetail: pres.statusDetail,
+ warehouseCode: p.warehouseCode,
+ traceLotNo: m.materialLotNo,
+ traceItemCode: m.materialItemCode,
+ categoryLabel: labels.categoryPutaway,
+ details: [
+ field(labels.detailStatus, pres.statusDetail),
+ field(labels.detailItemCode, m.materialItemCode),
+ field(labels.detailLot, m.materialLotNo),
+ field(
+ isTransferInboundPutaway(p.refType)
+ ? labels.detailInboundSiNo
+ : (p.refType ?? "").trim().toUpperCase() === "PO"
+ ? labels.detailPurchaseOrderNo
+ : (p.refType ?? "").trim().toUpperCase() === "JO"
+ ? labels.jobOrder
+ : labels.detailSourceDoc,
+ p.refCode,
+ {
+ linkKind,
+ linkCode: p.refCode,
+ linkId: p.refId,
+ }),
+ field(labels.detailPutawayBin, p.warehouseCode),
+ field(labels.detailQty, formatQty(p.qty, matUom)),
+ field(labels.detailHandler, p.handledBy),
+ field(labels.detailTime, p.timestamp),
+ ...pickOrderDetailFields(picks, labels.pickOrder),
+ ],
+ });
+ });
+
+ // ADJ stock-in: match FG — 上架 → 庫存調整 (入), not orphaned 「調整 · 原料入庫」.
+ if (origin && lotId != null) {
+ const originType = origin.type.trim().toUpperCase();
+ if (originType === "ADJ" && !ctx.seenInboundLots.has(lotId)) {
+ ctx.seenInboundLots.add(lotId);
+ const adjWh = lastMaterialPutawayWh;
+ const meta = [labels.tr.stockInStatus(origin.status)].filter(Boolean).join(" · ");
+ // Place at/after related putaway so chronological X and 上架 → 調整 edges match FG.
+ const lastPutawayTs = (m.putawayEvents ?? [])
+ .map((p) => p.timestamp)
+ .filter((t): t is string => Boolean(t?.trim()))
+ .sort()
+ .at(-1);
+ const adjTimestamp =
+ lastPutawayTs &&
+ parseSortKey(lastPutawayTs, 0) >= parseSortKey(origin.receiptDate, 0)
+ ? lastPutawayTs
+ : origin.receiptDate;
+ // Keep sortKey strictly after matching putaway so layout X stays 上架 → 調整
+ // (same calendar time previously ordered adj left of putaway → back-arrow).
+ const putawaySort = lastPutawayTs ? parseSortKey(lastPutawayTs, 0) : null;
+ const adjSortBase = parseSortKey(adjTimestamp, nextSeq(ctx));
+ nodes.push({
+ id: `mat-adj-${lotId}-${origin.stockInLineId}`,
+ kind: "ADJUSTMENT",
+ timestamp: origin.receiptDate,
+ sortKey:
+ putawaySort != null && adjSortBase <= putawaySort ? putawaySort + 1 : adjSortBase,
+ title: `${labels.nodeAdjustment} (${labels.tr.direction("IN")})`,
+ subtitle: appendPickToSubtitle(
+ [labels.tr.adjustmentType("ADJ"), origin.refCode || m.materialLotNo]
+ .filter(Boolean)
+ .join(" · ") || m.materialItemCode,
+ picks,
+ labels.pickOrder,
+ ),
+ qty: origin.acceptedQty,
+ uom: matUom,
+ adjustmentDirection: "IN",
+ meta: meta || undefined,
+ refType: origin.type,
+ refCode: origin.refCode,
+ refId: origin.refId,
+ warehouseCode: adjWh || undefined,
+ traceLotNo: m.materialLotNo,
+ traceItemCode: m.materialItemCode,
+ categoryLabel: labels.categoryAdjustment,
+ details: detailsOf(
+ field(labels.detailVariance, formatSignedQty(origin.acceptedQty, "IN", matUom)),
+ field(labels.detailAdjustmentRef, origin.refCode),
+ field(labels.detailItemCode, m.materialItemCode),
+ field(labels.detailLot, m.materialLotNo),
+ ...(adjWh ? [field(labels.detailWarehouse, adjWh)] : []),
+ field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
+ field(labels.detailTime, origin.receiptDate),
+ ...pickOrderDetailFields(picks, labels.pickOrder),
+ ),
+ });
+ }
+ }
+
+ return nodes;
+};
+
+const buildNestedJoCreatedNode = (
+ m: ItemLotTraceMaterialInput,
+ labels: JoPreludeGraphLabels,
+ picks: LotPickRef[],
+ ctx: PreludeBuildContext,
+): TraceGraphNode | null => {
+ const nested = m.nestedJoPrelude;
+ const jo = nested?.jobOrder;
+ if (!jo?.jobOrderCode?.trim()) return null;
+
+ const nestedInputs = nested?.materialInputs ?? [];
+ const nestedPickTs = nestedInputs
+ .map((input) => input.pickedAt)
+ .find((ts) => ts?.trim());
+ const nestedProdTs = (m.productionSteps ?? [])
+ .map((step) => step.startTime)
+ .find((ts) => ts?.trim());
+ const origin = m.stockInOrigin;
+ const ts =
+ jo.createdAt?.trim() ||
+ jo.planStart?.trim() ||
+ nestedPickTs?.trim() ||
+ nestedProdTs?.trim() ||
+ origin?.receiptDate?.trim() ||
+ null;
+ const matUom = m.materialUom?.trim() || "";
+ const qty = origin?.acceptedQty ?? jo.reqQty;
+
+ return {
+ id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`,
+ kind: "JO_CREATED",
+ timestamp: ts,
+ sortKey: parseSortKey(ts, nextSeq(ctx)),
+ title: labels.nodeJoCreated,
+ subtitle: appendPickToSubtitle(
+ [m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode,
+ picks,
+ labels.pickOrder,
+ ),
+ qty,
+ uom: matUom,
+ refType: "JO",
+ refCode: jo.jobOrderCode,
+ refId: jo.jobOrderId,
+ docLinkKind: "jo",
+ traceLotNo: m.materialLotNo,
+ traceItemCode: m.materialItemCode,
+ categoryLabel: labels.categoryProduction,
+ details: [
+ field(labels.detailType, labels.nodeJoCreated),
+ field(labels.jobOrder, jo.jobOrderCode, {
+ linkKind: "jo",
+ linkCode: jo.jobOrderCode,
+ linkId: jo.jobOrderId,
+ }),
+ field(labels.detailItemCode, m.materialItemCode),
+ field(labels.detailLot, m.materialLotNo),
+ field(labels.detailQty, formatQty(qty, matUom)),
+ field(labels.detailTime, ts),
+ ...pickOrderDetailFields(picks, labels.pickOrder),
+ ],
+ };
+};
+
+const buildMaterialInputChainNodes = (
+ materialInputs: ItemLotTraceMaterialInput[],
+ labels: JoPreludeGraphLabels & Partial,
+ lotPicks: Map,
+ ctx: PreludeBuildContext,
+): TraceGraphNode[] => {
+ const nodes: TraceGraphNode[] = [];
+
+ materialInputs.forEach((m) => {
+ const joProduced = isJoProducedMaterial(m);
+ const nestedInputs = m.nestedJoPrelude?.materialInputs ?? [];
+ const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo);
+
+ if (joProduced && m.nestedJoPrelude) {
+ const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx);
+ if (nestedJoCreated) nodes.push(nestedJoCreated);
+ }
+
+ if (joProduced && nestedInputs.length > 0) {
+ const nestedLotPicks = buildLotPickOrderMap(nestedInputs);
+ nodes.push(
+ ...buildMaterialInputChainNodes(
+ nestedInputs,
+ labels,
+ nestedLotPicks,
+ ctx,
+ ),
+ );
+ nestedInputs.forEach((nm, ni) => {
+ nodes.push(createMaterialPickNode(nm, ni, labels, pickCtx(ctx), m.materialLotNo));
+ });
+ }
+
+ if (joProduced && (m.productionSteps?.length ?? 0) > 0) {
+ nodes.push(...buildMaterialProductionNodes(m, labels, ctx));
+ }
+
+ nodes.push(...buildMaterialStockInPreludeNodes(m, labels, lotPicks, ctx));
+ });
+
+ return nodes;
+};
+
+export const buildJoPreludeGraphNodes = (
+ joPrelude: ItemLotTraceJoPrelude,
+ labels: JoPreludeGraphLabels & Partial,
+): TraceGraphNode[] => {
+ const ctx: PreludeBuildContext = {
+ seenPurchaseLines: new Set(),
+ seenInboundLots: new Set(),
+ seenReceiptLots: new Set(),
+ seenQcKeys: new Set(),
+ seenPutawayKeys: new Set(),
+ pickOrderTargetDateMap: buildPickOrderTargetDateMapFromPrelude(joPrelude),
+ seq: 0,
+ };
+ const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs);
+
+ const nodes: TraceGraphNode[] = [
+ ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx),
+ ...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))),
+ ];
+
+ return nodes.sort((a, b) => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ return a.id.localeCompare(b.id);
+ });
+};
diff --git a/src/components/ItemTracing/buildLocationBlockGraphNodes.ts b/src/components/ItemTracing/buildLocationBlockGraphNodes.ts
new file mode 100644
index 0000000..f936182
--- /dev/null
+++ b/src/components/ItemTracing/buildLocationBlockGraphNodes.ts
@@ -0,0 +1,172 @@
+import { ItemLotTraceLocationBlock, ItemLotTraceResponse } from "@/app/api/itemTracing";
+
+import {
+
+ buildTraceGraphNodes,
+
+ TraceGraphBuildScope,
+
+ TraceGraphDetailLabels,
+
+ TraceGraphNode,
+
+} from "./buildTraceGraphNodes";
+
+import {
+
+ buildExtendedTraceGraphNodes,
+
+ ExtendedTraceGraphLabels,
+
+} from "./buildExtendedTraceGraphNodes";
+
+
+
+export const blockWarehouseCode = (block: ItemLotTraceLocationBlock): string =>
+
+ block.warehouseLines
+
+ .map((w) => w.warehouseCode)
+
+ .filter(Boolean)
+
+ .join(" / ") || `Lot #${block.inventoryLotId}`;
+
+
+
+const blockToTraceResponse = (block: ItemLotTraceLocationBlock): ItemLotTraceResponse => {
+
+ const uom = block.uom ?? "";
+
+ return {
+
+ lot: {
+
+ inventoryLotId: block.inventoryLotId,
+
+ lotNo: block.lotNo,
+
+ itemId: 0,
+
+ itemCode: block.itemCode,
+
+ itemName: block.itemName ?? "",
+
+ expiryDate: block.expiryDate ?? null,
+
+ productionDate: block.productionDate ?? null,
+
+ stockInDate: block.stockInDate ?? null,
+
+ uom,
+
+ primaryStockInLineId: block.stockInLineId,
+
+ },
+
+ warehouseLines: block.warehouseLines,
+
+ origins: block.origins,
+
+ purchaseEvents: block.purchaseEvents ?? [],
+
+ qcResults: block.qcResults ?? [],
+
+ putawayEvents: block.putawayEvents ?? [],
+
+ movements: block.movements ?? [],
+
+ outboundUsage: block.outboundUsage ?? [],
+
+ stockTakeEvents: block.stockTakeEvents ?? [],
+
+ adjustments: block.adjustments ?? [],
+
+ transfers: block.transfers ?? [],
+
+ bomTrace: { direction: "UNKNOWN", upstream: [], downstream: [], bomRecipe: [] },
+
+ joPrelude: null,
+
+ productionSteps: [],
+
+ byproductLots: [],
+
+ openMovements: block.openMovements ?? [],
+
+ failEvents: block.failEvents ?? [],
+
+ doDeliveries: block.doDeliveries ?? [],
+
+ replenishmentEvents: block.replenishmentEvents ?? [],
+
+ returnEvents: block.returnEvents ?? [],
+
+ lotRelations: [],
+
+ alternateLocations: [],
+
+ locationBlocks: [],
+
+ traceGraph: null,
+
+ };
+
+};
+
+
+
+export const buildLocationBlockGraphNodes = (
+
+ block: ItemLotTraceLocationBlock,
+
+ labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels,
+
+): TraceGraphNode[] => {
+
+ const wh = blockWarehouseCode(block);
+
+ const scope: TraceGraphBuildScope = {
+ idPrefix: `loc-${block.inventoryLotId}-`,
+ locationBlockKeys: true,
+ inventoryLotId: block.inventoryLotId,
+ defaultWarehouseCode: wh,
+ mergedMultiLocation: true,
+ };
+
+ const synthetic = blockToTraceResponse(block);
+
+ const fgNodes = buildTraceGraphNodes(synthetic, labels, scope);
+
+ const extNodes =
+
+ labels.nodeOpen && labels.nodeFail && labels.nodeDoOut
+
+ ? buildExtendedTraceGraphNodes(synthetic, labels, scope)
+
+ : [];
+
+
+
+ return [...fgNodes, ...extNodes].sort((a, b) => {
+
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+
+ return a.id.localeCompare(b.id);
+
+ });
+
+};
+
+
+
+export const buildAllLocationBlockGraphNodes = (
+
+ blocks: ItemLotTraceLocationBlock[],
+
+ labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels,
+
+): TraceGraphNode[] =>
+
+ (blocks ?? []).flatMap((block) => buildLocationBlockGraphNodes(block, labels));
+
diff --git a/src/components/ItemTracing/buildProductionGraphNodes.ts b/src/components/ItemTracing/buildProductionGraphNodes.ts
new file mode 100644
index 0000000..86f7036
--- /dev/null
+++ b/src/components/ItemTracing/buildProductionGraphNodes.ts
@@ -0,0 +1,169 @@
+import {
+ ItemLotTraceByproductLot,
+ ItemLotTraceProductionStep,
+} from "@/app/api/itemTracing";
+import {
+ TraceGraphDetailLabels,
+ TraceGraphNode,
+} from "./buildTraceGraphNodes";
+import { formatQty } from "./traceQtyUtils";
+import { field, fieldIf, detailsOf, parseSortKey } from "./traceNodeFactory";
+
+export interface ProductionGraphLabels extends TraceGraphDetailLabels {
+ nodeProductionStep: string;
+ nodeByproduct: string;
+ nodeScrap: string;
+ nodeDefect: string;
+ detailProcessOutputQty: string;
+ detailProcessScrapQty: string;
+ detailProcessDefectQty: string;
+}
+
+const formatStepMaterials = (
+ materials: ItemLotTraceProductionStep["stepMaterials"],
+): string => {
+ if (!materials?.length) return "—";
+ return materials
+ .map((mat, i) => {
+ const label = [mat.materialItemCode, mat.materialItemName].filter(Boolean).join(" · ");
+ const qty = formatQty(mat.qtyPerUnit, mat.uom);
+ return `${i + 1}. ${label} (${qty})`;
+ })
+ .join("\n");
+};
+
+export const buildProductionGraphNodes = (
+ productionSteps: ItemLotTraceProductionStep[],
+ byproductLots: ItemLotTraceByproductLot[],
+ labels: ProductionGraphLabels,
+ lotUom: string,
+): TraceGraphNode[] => {
+ const nodes: TraceGraphNode[] = [];
+ let seq = 0;
+
+ productionSteps.forEach((step) => {
+ const ts = step.endTime ?? step.startTime;
+ const equip = [step.equipmentCode, step.equipmentName].filter(Boolean).join(" ");
+ const stepMaterialsLabel = formatStepMaterials(step.stepMaterials);
+ const materialCodesList = (step.stepMaterials ?? [])
+ .map((mat) => mat.materialItemCode?.trim())
+ .filter((code): code is string => Boolean(code));
+ const materialCodes = materialCodesList.join(" · ");
+ nodes.push({
+ id: `prod-step-${step.processLineId}`,
+ kind: "PRODUCTION_STEP",
+ timestamp: ts,
+ sortKey: parseSortKey(ts, seq++),
+ title: step.stepName || labels.nodeProductionStep,
+ subtitle: [materialCodes, equip, labels.tr.productionStatus(step.status)]
+ .filter(Boolean)
+ .join(" · ") || "—",
+ qty: step.outputQty,
+ uom: lotUom,
+ meta: step.operatorName ? `${labels.detailHandler}: ${step.operatorName}` : undefined,
+ refType: "JO",
+ bomProcessId: step.bomProcessId,
+ bomProcessSeqNo: step.seqNo,
+ assignedStepName: step.stepName,
+ stepMaterialItemCodes: materialCodesList.length ? materialCodesList : undefined,
+ categoryLabel: labels.categoryProduction,
+ details: detailsOf(
+ field(labels.detailType, labels.nodeProductionStep),
+ field(labels.detailStatus, labels.tr.productionStatus(step.status)),
+ field(labels.detailProcessStep, step.stepName),
+ field(labels.detailStepMaterials, stepMaterialsLabel),
+ fieldIf(labels.detailProcessDescription, step.description),
+ field(labels.detailHandler, step.operatorName),
+ field(labels.detailTime, ts),
+ field(labels.detailProcessOutputQty, formatQty(step.outputQty, lotUom)),
+ field(labels.detailProcessScrapQty, formatQty(step.scrapQty, lotUom)),
+ field(labels.detailProcessDefectQty, formatQty(step.defectQty, lotUom)),
+ field(labels.detailEquipment, equip || "—"),
+ ),
+ });
+
+ const lossQty = (step.scrapQty ?? 0) + (step.defectQty ?? 0);
+ if (lossQty > 0) {
+ if ((step.scrapQty ?? 0) > 0) {
+ nodes.push({
+ id: `scrap-${step.processLineId}`,
+ kind: "SCRAP",
+ timestamp: ts,
+ sortKey: parseSortKey(ts, seq++) + 1,
+ title: labels.nodeScrap,
+ subtitle: step.stepName || "—",
+ qty: step.scrapQty,
+ uom: lotUom,
+ bomProcessId: step.bomProcessId,
+ bomProcessSeqNo: step.seqNo,
+ assignedStepName: step.stepName,
+ categoryLabel: labels.categoryProduction,
+ details: [
+ field(labels.detailType, labels.nodeScrap),
+ field(labels.detailProcessStep, step.stepName),
+ field(labels.detailProcessScrapQty, formatQty(step.scrapQty, lotUom)),
+ field(labels.detailTime, ts),
+ ],
+ });
+ }
+ if ((step.defectQty ?? 0) > 0) {
+ nodes.push({
+ id: `defect-${step.processLineId}`,
+ kind: "DEFECT",
+ timestamp: ts,
+ sortKey: parseSortKey(ts, seq++) + 2,
+ title: labels.nodeDefect,
+ subtitle: step.stepName || "—",
+ qty: step.defectQty,
+ uom: lotUom,
+ bomProcessId: step.bomProcessId,
+ bomProcessSeqNo: step.seqNo,
+ assignedStepName: step.stepName,
+ categoryLabel: labels.categoryProduction,
+ details: [
+ field(labels.detailType, labels.nodeDefect),
+ field(labels.detailProcessStep, step.stepName),
+ field(labels.detailProcessDefectQty, formatQty(step.defectQty, lotUom)),
+ field(labels.detailTime, ts),
+ ],
+ });
+ }
+ }
+ });
+
+ byproductLots.forEach((bp, i) => {
+ const subtitle = [bp.lotNo, bp.processStepName].filter(Boolean).join(" · ") || bp.itemName;
+ nodes.push({
+ id: `byproduct-${bp.inventoryLotId ?? i}-${bp.lotNo}`,
+ kind: "BYPRODUCT",
+ timestamp: bp.producedAt,
+ sortKey: parseSortKey(bp.producedAt, seq++),
+ title: `${bp.itemCode} · ${labels.nodeByproduct}`,
+ subtitle,
+ qty: bp.qty,
+ uom: bp.uom,
+ refType: "JO",
+ refCode: bp.jobOrderCode,
+ refId: bp.jobOrderId,
+ docLinkKind: "jo",
+ traceLotNo: bp.lotNo,
+ traceItemCode: bp.itemCode,
+ categoryLabel: labels.categoryProduction,
+ details: [
+ field(labels.detailType, labels.nodeByproduct),
+ field(labels.detailMaterial, `${bp.itemCode} · ${bp.itemName}`),
+ field(labels.detailLot, bp.lotNo),
+ field(labels.detailQty, formatQty(bp.qty, bp.uom)),
+ field(labels.detailTime, bp.producedAt),
+ field(labels.jobOrder, bp.jobOrderCode, {
+ linkKind: "jo",
+ linkCode: bp.jobOrderCode,
+ linkId: bp.jobOrderId,
+ }),
+ field(labels.detailProcessStep, bp.processStepName),
+ ],
+ });
+ });
+
+ return nodes;
+};
diff --git a/src/components/ItemTracing/buildReactFlowGraph.ts b/src/components/ItemTracing/buildReactFlowGraph.ts
new file mode 100644
index 0000000..b148797
--- /dev/null
+++ b/src/components/ItemTracing/buildReactFlowGraph.ts
@@ -0,0 +1,323 @@
+import { MarkerType, type Edge, type Node } from "@xyflow/react";
+import {
+ COLUMN_WIDTH_MIN,
+ HEADER_HEIGHT,
+ LANE_GAP,
+ LANE_MIN_HEIGHT,
+ NODE_HEIGHT,
+ NODE_WIDTH,
+ NODE_WIDTH_BRANCH,
+ PHASE_LABEL_WIDTH,
+} from "./traceFlowConstants";
+import {
+ CELL_PAD_X,
+ layoutCellMembers,
+ computeLaneTops,
+} from "./traceFlowLayout";
+import { phaseAccent } from "./traceFlowNodeUtils";
+import { TraceGraphLayout, TraceGraphLayoutNode, buildTraceFlowEdgePairs } from "./traceGraphLayout";
+import type { TraceFlowEdgePair } from "./traceGraphSemantics";
+import {
+ assignTraceFlowEdgeRouting,
+ enrichTraceFlowCorridorRouting,
+ type TraceFlowNodeRect,
+} from "./traceFlowEdgeLayout";
+import {
+ isDoGroupChild,
+ isFlowGroupContainer,
+ layoutDoGroupChildPlacements,
+} from "./traceDoGroupLayout";
+
+export type TraceFlowNodeData = {
+ layoutNode: TraceGraphLayoutNode;
+ compact: boolean;
+ onTrace?: (params: { stockInLineId?: number; lotNo?: string; itemCode?: string }) => void;
+ phaseLabel?: string;
+ dateLabel?: string;
+ phaseColor?: string;
+ incomingHandleCount?: number;
+ outgoingHandleCount?: number;
+ searchActive?: boolean;
+ searchMatch?: boolean;
+ searchFocused?: boolean;
+ nodeSelected?: boolean;
+ /** DO / pick group header collapse. */
+ groupCollapsed?: boolean;
+ onToggleGroupCollapse?: () => void;
+};
+
+const cellKey = (laneIndex: number, column: number, stagger: number) =>
+ `${laneIndex}:${column}:${stagger}`;
+
+export const buildReactFlowGraph = (
+ layout: TraceGraphLayout,
+ phaseLabels: Record,
+ edgePairs?: TraceFlowEdgePair[],
+): { nodes: Node[]; edges: Edge[]; graphHeight: number } => {
+ const nodes: Node[] = [];
+ const topLevelNodes = layout.nodes.filter((n) => !isDoGroupChild(n));
+
+ const cells = new Map();
+ topLevelNodes.forEach((n) => {
+ const key = cellKey(n.laneIndex, n.column, n.dayPhaseStaggerIndex);
+ const list = cells.get(key) ?? [];
+ list.push(n);
+ cells.set(key, list);
+ });
+
+ const cellLayouts = new Map>();
+ const { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts } = layout.dayColumns;
+
+ cells.forEach((members, key) => {
+ const col = Number(key.split(":")[1]);
+ const stagger = members[0]?.dayPhaseStaggerIndex ?? 0;
+ const slotW = phaseSlotWidths[col]?.[stagger] ?? COLUMN_WIDTH_MIN;
+ cellLayouts.set(key, layoutCellMembers(members, slotW - CELL_PAD_X * 2));
+ });
+
+ const doChildrenByGroup = new Map();
+ layout.nodes.forEach((n) => {
+ if (!n.doGroupId) return;
+ const list = doChildrenByGroup.get(n.doGroupId) ?? [];
+ list.push(n);
+ doChildrenByGroup.set(n.doGroupId, list);
+ });
+
+ const childPlacementsByGroup = new Map<
+ string,
+ ReturnType
+ >();
+ doChildrenByGroup.forEach((children, groupId) => {
+ childPlacementsByGroup.set(groupId, layoutDoGroupChildPlacements(children));
+ });
+
+ const { laneTops, laneHeights, totalHeight } = computeLaneTops(layout.laneCount, cells);
+
+ layout.columnDates.forEach((date, col) => {
+ const dayW = columnWidths[col] ?? COLUMN_WIDTH_MIN;
+ const dayStart = columnStarts[col] ?? col * COLUMN_WIDTH_MIN;
+ nodes.push({
+ id: `hdr-${col}`,
+ type: "dateHeader",
+ position: {
+ x: PHASE_LABEL_WIDTH + dayStart + dayW / 2 - 40,
+ y: 6,
+ },
+ data: { layoutNode: {} as TraceGraphLayoutNode, compact: false, dateLabel: date },
+ draggable: false,
+ selectable: false,
+ connectable: false,
+ focusable: false,
+ });
+ });
+
+ layout.phaseOrder.forEach((phase, laneIdx) => {
+ const laneH = laneHeights[laneIdx] ?? LANE_MIN_HEIGHT;
+ nodes.push({
+ id: `phase-${phase}`,
+ type: "phaseLabel",
+ position: {
+ x: 8,
+ y: laneTops[laneIdx] + Math.max(8, (laneH - 80) / 2),
+ },
+ data: {
+ layoutNode: {} as TraceGraphLayoutNode,
+ compact: false,
+ phaseLabel: phaseLabels[phase] ?? phase,
+ phaseColor: phaseAccent(phase),
+ },
+ draggable: false,
+ selectable: false,
+ connectable: false,
+ focusable: false,
+ });
+ });
+
+ topLevelNodes.forEach((layoutNode) => {
+ const key = cellKey(layoutNode.laneIndex, layoutNode.column, layoutNode.dayPhaseStaggerIndex);
+ const placed = cellLayouts.get(key)?.get(layoutNode.id);
+ if (!placed) return;
+
+ const dayStart = columnStarts[layoutNode.column] ?? layoutNode.column * COLUMN_WIDTH_MIN;
+ const phaseSlotX =
+ phaseSlotStarts[layoutNode.column]?.[layoutNode.dayPhaseStaggerIndex] ?? 0;
+ const isGroup = isFlowGroupContainer(layoutNode);
+ const groupW = layoutNode.groupBoxWidth ?? NODE_WIDTH;
+ const groupH = layoutNode.groupBoxHeight ?? NODE_HEIGHT;
+
+ nodes.push({
+ id: layoutNode.id,
+ type: layoutNode.kind === "PICK_GROUP" ? "pickGroup" : isGroup ? "doGroup" : "traceEvent",
+ position: {
+ x: PHASE_LABEL_WIDTH + dayStart + phaseSlotX + CELL_PAD_X + placed.x,
+ y: laneTops[layoutNode.laneIndex] + placed.y,
+ },
+ data: { layoutNode, compact: placed.compact },
+ draggable: false,
+ connectable: false,
+ selectable: !isGroup,
+ zIndex: isGroup ? 0 : 1,
+ style: isGroup ? { width: groupW, height: groupH } : undefined,
+ width: isGroup ? groupW : undefined,
+ height: isGroup ? groupH : undefined,
+ measured: {
+ width: isGroup ? groupW : placed.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH,
+ height: isGroup ? groupH : NODE_HEIGHT,
+ },
+ });
+ });
+
+ const parentPositions = new Map();
+ nodes.forEach((node) => {
+ if (node.type === "doGroup" || node.type === "pickGroup") {
+ parentPositions.set(node.id, node.position);
+ }
+ });
+
+ doChildrenByGroup.forEach((children, groupId) => {
+ const placements = childPlacementsByGroup.get(groupId);
+ const parentPos = parentPositions.get(groupId);
+ if (!placements || !parentPos) return;
+
+ children.forEach((layoutNode) => {
+ const placed = placements.get(layoutNode.id);
+ if (!placed) return;
+
+ nodes.push({
+ id: layoutNode.id,
+ type: "traceEvent",
+ position: {
+ x: parentPos.x + placed.x,
+ y: parentPos.y + placed.y,
+ },
+ data: { layoutNode, compact: placed.compact },
+ draggable: false,
+ connectable: false,
+ zIndex: 2,
+ width: NODE_WIDTH,
+ height: NODE_HEIGHT,
+ measured: {
+ width: NODE_WIDTH,
+ height: NODE_HEIGHT,
+ },
+ });
+ });
+ });
+
+ const flowPairs = edgePairs ?? buildTraceFlowEdgePairs(layout.nodes, layout.phaseOrder);
+ const { routed, incomingCount, outgoingCount } = assignTraceFlowEdgeRouting(flowPairs);
+
+ const nodeRects = new Map();
+ nodes.forEach((node) => {
+ if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return;
+ const layoutNode = node.data.layoutNode;
+ if (layoutNode.doGroupId) return;
+ nodeRects.set(node.id, {
+ x: node.position.x,
+ y: node.position.y,
+ width: node.measured?.width ?? NODE_WIDTH,
+ height: node.measured?.height ?? NODE_HEIGHT,
+ laneIndex: layoutNode.laneIndex,
+ });
+ });
+
+ const corridorRouted = enrichTraceFlowCorridorRouting(routed, nodeRects, {
+ laneTops,
+ laneHeights,
+ laneGap: LANE_GAP,
+ });
+
+ const nodesWithHandles = nodes.map((node) => {
+ if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return node;
+ if (node.data.layoutNode.doGroupId) {
+ return {
+ ...node,
+ data: {
+ ...node.data,
+ incomingHandleCount: 0,
+ outgoingHandleCount: 0,
+ },
+ };
+ }
+ return {
+ ...node,
+ data: {
+ ...node.data,
+ incomingHandleCount: incomingCount.get(node.id) ?? 0,
+ outgoingHandleCount: outgoingCount.get(node.id) ?? 0,
+ },
+ };
+ });
+
+ const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
+
+ const edges: Edge[] = corridorRouted.map(
+ ({
+ fromId,
+ toId,
+ sourceHandle,
+ targetHandle,
+ offset,
+ pathMode,
+ corridorY,
+ corridorEntryOffset,
+ corridorExitOffset,
+ corridorBranchOffset,
+ }) => {
+ const targetNode = nodeById.get(toId);
+ const ref = targetNode?.refCode?.trim();
+ const flowLabel =
+ targetNode?.kind === "DO_GROUP" && targetNode.groupMemberCount
+ ? String(targetNode.groupMemberCount)
+ : targetNode?.kind === "PICK_GROUP" && ref
+ ? ref
+ : targetNode?.kind === "MATERIAL_PICK" && ref
+ ? ref
+ : (targetNode?.kind === "OUT" || targetNode?.kind === "DO_OUT") && ref
+ ? ref
+ : undefined;
+ return {
+ id: `e-${fromId}-${toId}`,
+ source: fromId,
+ target: toId,
+ sourceHandle,
+ targetHandle,
+ type: "traceFlow",
+ data: {
+ offset,
+ pathMode,
+ corridorY,
+ corridorEntryOffset,
+ corridorExitOffset,
+ corridorBranchOffset,
+ },
+ label: flowLabel,
+ labelStyle: { fontSize: 10, fontWeight: 600, fill: "#5d4037" },
+ labelBgStyle: { fill: "#fff8e1", fillOpacity: 0.95 },
+ labelBgPadding: [4, 6] as [number, number],
+ labelBgBorderRadius: 4,
+ style: { stroke: "#757575", strokeWidth: 1.5, opacity: 0.75 },
+ markerEnd: { type: MarkerType.ArrowClosed, color: "#757575", width: 16, height: 16 },
+ };
+ });
+
+ return { nodes: nodesWithHandles, edges, graphHeight: totalHeight };
+};
+
+export const reactFlowGraphExtent = (
+ layout: TraceGraphLayout,
+ graphHeight?: number,
+): [[number, number], [number, number]] => {
+ const width = PHASE_LABEL_WIDTH + layout.dayColumns.timelineWidth + 32;
+ const height = graphHeight ?? HEADER_HEIGHT + layout.laneCount * LANE_MIN_HEIGHT + 32;
+ // Extra bottom room so expanded stock-take lifecycle panels stay pannable.
+ const padX = 48;
+ const padTop = 48;
+ const padBottom = 360;
+ return [
+ [-padX, -padTop],
+ [width + padX, height + padBottom],
+ ];
+};
+
+export type ReactFlowGraphResult = ReturnType;
diff --git a/src/components/ItemTracing/buildTraceGraphNodes.ts b/src/components/ItemTracing/buildTraceGraphNodes.ts
new file mode 100644
index 0000000..62ad86a
--- /dev/null
+++ b/src/components/ItemTracing/buildTraceGraphNodes.ts
@@ -0,0 +1,1223 @@
+import dayjs from "dayjs";
+import { ItemLotTraceJoContext, ItemLotTraceOrigin, ItemLotTraceQcResult, ItemLotTraceResponse, ItemLotTraceStockTakeRecordDetail } from "@/app/api/itemTracing";
+import { TraceLabelTranslator, groupQcResultsBySession, resolveDoOutboundChipFlags } from "./traceLabelUtils";
+import { formatQty, formatSignedQty } from "./traceQtyUtils";
+import { docLinkFromRefType, detailsOf, field, fieldIf, parseExpirySortKey, parseSortKey, pickStatusDetailFields, pickStatusNodeLabels } from "./traceNodeFactory";
+import {
+ isPendingPutawayOriginStatus,
+ isTransferInboundPutaway,
+ matchTransferForInboundPutaway,
+ normRefType,
+ resolvePutawayPresentation,
+ shouldEmitDepletedForScope,
+ shouldEmitTransferForScope,
+} from "./tracePutawayUtils";
+import { resolveDoOutboundDocLink, resolveJoPickDocLink, buildPickOrderTargetDateMap, resolvePickOrderTargetDate } from "./traceDocLinkUtils";
+import {
+ formatStockTakeRoundLabel,
+ resolveStockTakeAcceptedQty,
+ resolveStockTakeBookQty,
+ stockTakeEventSubtitle,
+} from "./traceStockTakeUtils";
+
+export type TraceGraphNodeKind =
+ | "MATERIAL_IN"
+ | "JO_CREATED"
+ | "MATERIAL_QC"
+ | "MATERIAL_PICK"
+ | "PRODUCTION_STEP"
+ | "BYPRODUCT"
+ | "SCRAP"
+ | "DEFECT"
+ | "OPEN"
+ | "FAIL"
+ | "DO_OUT"
+ | "REPLENISHMENT_CREATED"
+ | "JO_OUT"
+ | "PO_OUT"
+ | "DO_GROUP"
+ | "PICK_GROUP"
+ | "RETURN"
+ | "REPACK"
+ | "PURCHASE"
+ | "RECEIPT"
+ | "PUTAWAY"
+ | "IN"
+ | "OUT"
+ | "QC"
+ | "STOCK_TAKE"
+ | "ADJUSTMENT"
+ | "TRANSFER"
+ | "EXPIRED"
+ | "DEPLETED";
+
+export type TraceGraphDocLinkKind = "jo" | "po" | "pick" | "workbench" | "jodetail";
+
+export interface TraceGraphDetailField {
+ label: string;
+ value: string;
+ linkKind?: TraceGraphDocLinkKind;
+ linkCode?: string;
+ linkId?: number | null;
+ consoCode?: string;
+ linkTicketNo?: string;
+ linkTargetDate?: string;
+ variant?: "default" | "qcCriteriaList";
+ qcCriteriaItems?: QcCriteriaItem[];
+ /** MUI palette key for status value text (success / error / warning / info). */
+ valueColor?: "success" | "error" | "warning" | "info" | "default";
+}
+
+export interface QcCriteriaItem {
+ name: string;
+ description?: string;
+ passed: boolean;
+ failQty?: number;
+}
+
+export interface TraceGraphNodeLabels {
+ nodeReceipt: string;
+ nodePurchase: string;
+ nodePutaway: string;
+ nodePutawayTransfer: string;
+ putawayTransferDetail: string;
+ nodeJoCreated: string;
+ nodeQcPass: string;
+ nodeQcFail: string;
+ nodeStockTake: string;
+ nodeAdjustment: string;
+ nodeTransfer: string;
+ nodeDoOut: string;
+ nodeJoOut: string;
+ nodePoOut: string;
+ directionIn: string;
+ directionOut: string;
+ formatQcSubtitle: (failQty: number, acceptedQty: number) => string;
+}
+
+export interface TraceGraphNode {
+ id: string;
+ kind: TraceGraphNodeKind;
+ timestamp: string | null;
+ sortKey: number;
+ title: string;
+ subtitle: string;
+ qty?: number;
+ uom?: string;
+ meta?: string;
+ refType?: string;
+ refCode?: string;
+ refId?: number | null;
+ docLinkKind?: TraceGraphDocLinkKind;
+ /** Workbench deep-link ticket (TI-*). */
+ docLinkTicketNo?: string;
+ docLinkTargetDate?: string;
+ traceLotNo?: string;
+ traceItemCode?: string;
+ consoCode?: string;
+ categoryLabel?: string;
+ /** Translated QC type for chip label (IQC / EPQC / …). */
+ qcTypeLabel?: string;
+ /** 待上架 / 已上架 for PUTAWAY nodes (chip). */
+ putawayStatusLabel?: string;
+ /** 加單 / 補貨 flags for DO_OUT title chips (detail uses outboundKindLabel). */
+ doOutboundIsExtra?: boolean;
+ doOutboundIsReplenish?: boolean;
+ /** 改數 — pick line qty ≠ sum of stock-out lines for that pick line. */
+ doOutboundQtyChanged?: boolean;
+ outboundKindLabel?: string;
+ /** Longer status explanation in detail panel (e.g. transfer inbound). */
+ putawayStatusDetail?: string;
+ /** Warehouse where stock was shelved (PUTAWAY) or transfer endpoints (TRANSFER). */
+ warehouseCode?: string;
+ transferFromWarehouse?: string;
+ transferToWarehouse?: string;
+ /** BOM process step this node belongs to (material pick / production step mapping). */
+ bomProcessId?: number | null;
+ bomProcessSeqNo?: number | null;
+ assignedStepName?: string;
+ /** Item codes consumed by this production step (from BOM process materials). */
+ stepMaterialItemCodes?: string[];
+ /** Semi-finished lot whose nested production this pick feeds (nested JO only). */
+ feedsProductionScopeLotNo?: string;
+ /** Job order that owns this material pick / pick group. */
+ jobOrderCode?: string;
+ details: TraceGraphDetailField[];
+ /** When set, this node is rendered inside a DO group box. */
+ doGroupId?: string;
+ /** DO_GROUP container size (layout only). */
+ groupBoxWidth?: number;
+ groupBoxHeight?: number;
+ groupMemberCount?: number;
+ /** Summed outbound qty for DO_GROUP header. */
+ groupTotalQty?: number;
+ groupUom?: string;
+ /** Links REPLENISHMENT_CREATED → matching DO_OUT (stockOutLineId). */
+ replenishmentStockOutLineId?: number;
+ /** IN / OUT for ADJUSTMENT nodes (signed variance display). */
+ adjustmentDirection?: string;
+ /** Stocktake lifecycle record detail (for expandable STOCK_TAKE nodes). */
+ stockTakeRecordDetail?: ItemLotTraceStockTakeRecordDetail;
+ /** Stocktake variance (accepted − book); null when round has no accepted qty yet. */
+ stockTakeVarianceQty?: number | null;
+ /** Owning inventory_lot for multi-location graph scopes. */
+ inventoryLotId?: number;
+ /** Translated pick-line statuses for JO pick cards. */
+ processingStatusLabel?: string;
+ matchStatusLabel?: string;
+ /** Raw status codes (for coloring). */
+ processingStatus?: string;
+ matchStatus?: string;
+}
+
+export interface TraceGraphDetailLabels extends TraceGraphNodeLabels {
+ tr: TraceLabelTranslator;
+ detailQcCriteria: string;
+ detailQcType: string;
+ detailQcUnknownItem: string;
+ nodeExpired: string;
+ nodeDepleted: string;
+ detailQty: string;
+ detailTime: string;
+ detailHandler: string;
+ detailStockTaker: string;
+ detailApprover: string;
+ detailWarehouse: string;
+ detailRemarks: string;
+ detailFailCategory: string;
+ detailProcessDescription: string;
+ detailType: string;
+ detailRef: string;
+ detailStockTakeCode: string;
+ detailAdjustmentRef: string;
+ detailReplenishmentCode: string;
+ detailReason: string;
+ detailReturnRef: string;
+ detailSourceDoc: string;
+ detailPurchaseOrderNo: string;
+ detailInboundRef: string;
+ detailInboundSiNo: string;
+ detailPutawayBin: string;
+ detailDirection: string;
+ detailStatus: string;
+ detailSupplier: string;
+ detailMaterial: string;
+ detailItemCode: string;
+ detailItemName: string;
+ detailLot: string;
+ detailAcceptedQty: string;
+ detailFailQty: string;
+ detailFrom: string;
+ detailTo: string;
+ detailVariance: string;
+ detailBefore: string;
+ detailAfter: string;
+ detailStockTakeFirstCount: string;
+ detailStockTakeSecondCount: string;
+ detailStockTakeApproverCount: string;
+ detailScrapQty: string;
+ detailDefectQty: string;
+ detailEquipment: string;
+ detailProcessStep: string;
+ detailStepMaterials: string;
+ detailAssignedStep: string;
+ detailPickTargetDate: string;
+ detailProductLotNo: string;
+ detailOrderQty: string;
+ detailPutAwayQty: string;
+ detailPurchaseUnit: string;
+ detailSupplyTo: string;
+ detailStockTakeRound: string;
+ detailStockTakeSection: string;
+ detailLocation: string;
+ deliveryOrder: string;
+ deliveryNoteCode: string;
+ ticketNo: string;
+ pickOrder: string;
+ jobOrder: string;
+ expiryDate: string;
+ totalAvailable: string;
+ itemCode: string;
+ categoryPurchase: string;
+ categoryProduction: string;
+ categoryInbound: string;
+ categoryReceipt: string;
+ categoryPutaway: string;
+ categoryTransfer: string;
+ categoryStockTake: string;
+ categoryOpen: string;
+ categoryAdjustment: string;
+ categoryPick: string;
+ categoryQc: string;
+ categoryOutbound: string;
+ categoryTerminal: string;
+ processingStatus: string;
+ matchStatus: string;
+}
+
+const categoryForKind = (kind: TraceGraphNodeKind, labels: TraceGraphDetailLabels): string => {
+ switch (kind) {
+ case "MATERIAL_IN":
+ return labels.categoryPurchase;
+ case "JO_CREATED":
+ return labels.categoryProduction;
+ case "MATERIAL_PICK":
+ return labels.categoryPick;
+ case "PRODUCTION_STEP":
+ case "BYPRODUCT":
+ case "SCRAP":
+ case "DEFECT":
+ return labels.categoryProduction;
+ case "OPEN":
+ return labels.categoryOpen;
+ case "FAIL":
+ return labels.categoryQc;
+ case "DO_OUT":
+ case "JO_OUT":
+ case "PO_OUT":
+ case "RETURN":
+ return labels.categoryOutbound;
+ case "REPACK":
+ return labels.categoryTransfer;
+ case "PURCHASE":
+ return labels.categoryPurchase;
+ case "RECEIPT":
+ return labels.categoryReceipt;
+ case "PUTAWAY":
+ return labels.categoryPutaway;
+ case "MATERIAL_QC":
+ case "QC":
+ return labels.categoryQc;
+ case "IN":
+ return labels.categoryInbound;
+ case "TRANSFER":
+ return labels.categoryTransfer;
+ case "STOCK_TAKE":
+ return labels.categoryStockTake;
+ case "ADJUSTMENT":
+ return labels.categoryAdjustment;
+ case "OUT":
+ return labels.categoryOutbound;
+ case "EXPIRED":
+ case "DEPLETED":
+ return labels.categoryTerminal;
+ default:
+ return labels.detailType;
+ }
+};
+
+/** Movement refTypes covered by dedicated trace arrays (avoid duplicate graph nodes). */
+const MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = new Set([
+ "TRANSFER",
+ "ADJ",
+ "OPEN",
+ "TKE",
+]);
+
+/** Stock-take ledger movements are represented by stockTakeEvents nodes. */
+const isStockTakeLedgerMovement = (movementType: string) => {
+ const mt = movementType.trim().toUpperCase();
+ return mt === "TKE" || mt === "STOCKTAKE";
+};
+
+const norm = (v: string | null | undefined) => (v ?? "").trim().toUpperCase();
+
+const doMovementDedupKey = (
+ pickOrderCode: string,
+ timestamp: string | null | undefined,
+ qty: number,
+) => `${pickOrderCode.trim()}|${timestamp ?? ""}|${qty}`;
+
+const buildCoveredDoMovementKeys = (data: ItemLotTraceResponse): Set =>
+ new Set(
+ (data.doDeliveries ?? []).map((d) =>
+ doMovementDedupKey(d.pickOrderCode, d.timestamp, d.qty),
+ ),
+ );
+
+export interface TraceGraphBuildScope {
+ /** e.g. `loc-42-` for alternate inventory_lot nodes (aligned with backend traceGraph keys). */
+ idPrefix?: string;
+ locationBlockKeys?: boolean;
+ inventoryLotId?: number;
+ defaultWarehouseCode?: string;
+ /** When true, TRANSFER cards are omitted; destination shows 轉倉入庫 putaway only. */
+ mergedMultiLocation?: boolean;
+}
+
+export const scopePrefix = (scope?: TraceGraphBuildScope) => scope?.idPrefix ?? "";
+
+export const withNodeScope = (node: TraceGraphNode, scope?: TraceGraphBuildScope): TraceGraphNode => {
+ if (!scope?.inventoryLotId && !scope?.defaultWarehouseCode) return node;
+ return {
+ ...node,
+ inventoryLotId: scope.inventoryLotId ?? node.inventoryLotId,
+ warehouseCode: node.warehouseCode?.trim() || scope.defaultWarehouseCode || node.warehouseCode,
+ };
+};
+
+const buildQcNodes = (
+ qcResults: ItemLotTraceQcResult[],
+ labels: TraceGraphDetailLabels,
+ stockUom: string,
+ seqStart: number,
+ scope?: TraceGraphBuildScope,
+): { nodes: TraceGraphNode[]; nextSeq: number } => {
+ const idPfx = scopePrefix(scope);
+ const lb = scope?.locationBlockKeys ?? false;
+ const nodes: TraceGraphNode[] = [];
+ let seq = seqStart;
+ groupQcResultsBySession(qcResults).forEach((group, i) => {
+ const first = group[0];
+ if (!first) return;
+ const allPassed = group.every((q) => q.qcPassed);
+ const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0);
+ const metaParts = [
+ first.handledBy,
+ ...group.map((q) => q.remarks).filter(Boolean),
+ ].filter(Boolean);
+ const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? "";
+ nodes.push({
+ id: lb
+ ? `${idPfx}qc-${i}-${first.stockInLineId}`
+ : `${idPfx}qc-${i}-${first.stockInLineId}-${first.created}`,
+ kind: "QC",
+ timestamp: first.created,
+ sortKey: parseSortKey(first.created, seq++),
+ title: allPassed ? labels.nodeQcPass : labels.nodeQcFail,
+ subtitle: "",
+ qty: totalFail,
+ uom: stockUom,
+ meta: metaParts.length ? Array.from(new Set(metaParts)).join(" · ") : undefined,
+ categoryLabel: labels.categoryQc,
+ qcTypeLabel: labels.tr.qcType(qcTypeRaw),
+ details: detailsOf(
+ field(labels.detailStatus, labels.tr.qcPassed(allPassed)),
+ field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)),
+ field(labels.detailQcCriteria, "", {
+ variant: "qcCriteriaList",
+ qcCriteriaItems: group.map((q) => ({
+ name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem,
+ description: q.qcItemDescription?.trim() || undefined,
+ passed: q.qcPassed,
+ failQty: q.failQty > 0 ? q.failQty : undefined,
+ })),
+ }),
+ field(labels.detailAcceptedQty, formatQty(first.acceptedQty, stockUom)),
+ field(labels.detailFailQty, formatQty(totalFail, stockUom)),
+ field(labels.detailHandler, first.handledBy),
+ field(labels.detailTime, first.created),
+ fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")),
+ ),
+ });
+ });
+ return { nodes, nextSeq: seq };
+};
+
+const resolveJoCreatedTimestamp = (
+ data: ItemLotTraceResponse,
+ refCode: string | null | undefined,
+ fallback: string | null | undefined,
+): string | null => {
+ const code = refCode?.trim();
+ if (!code) return fallback ?? null;
+
+ const candidates: Array = [
+ data.joPrelude?.jobOrder,
+ ...(data.joPrelude?.materialInputs ?? []).map((m) => m.nestedJoPrelude?.jobOrder),
+ ];
+
+ for (const jo of candidates) {
+ if (jo?.jobOrderCode?.trim() === code && jo.createdAt?.trim()) {
+ return jo.createdAt;
+ }
+ }
+ return fallback ?? null;
+};
+
+const poInboundKey = (
+ refCode: string | null | undefined,
+ refId: number | null | undefined,
+ qty: number,
+): string => `${(refCode ?? "").trim()}|${refId ?? 0}|${qty}`;
+
+const buildInboundOriginNodes = (
+ data: ItemLotTraceResponse,
+ labels: TraceGraphDetailLabels,
+ stockUom: string,
+ seqStart: number,
+ scope: TraceGraphBuildScope | undefined,
+ existingPurchaseCodes: Set,
+ coveredPoInbound: Set,
+ coveredJoInbound: Set,
+): { nodes: TraceGraphNode[]; nextSeq: number } => {
+ const idPfx = scopePrefix(scope);
+ const lb = scope?.locationBlockKeys ?? false;
+ const defaultWh = scope?.defaultWarehouseCode;
+ const nodes: TraceGraphNode[] = [];
+ let seq = seqStart;
+
+ (data.origins ?? []).forEach((origin, i) => {
+ const originType = norm(origin.type);
+ const linkKind = docLinkFromRefType(origin.type);
+ const supplierLabel = [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ");
+
+ if (originType === "PO") {
+ const refCode = origin.refCode?.trim();
+ if (!refCode) return;
+ coveredPoInbound.add(poInboundKey(origin.refCode, origin.refId, origin.acceptedQty));
+
+ if (!existingPurchaseCodes.has(refCode)) {
+ existingPurchaseCodes.add(refCode);
+ nodes.push({
+ id: lb
+ ? `${idPfx}purchase-origin-${origin.refId ?? i}-${origin.stockInLineId}`
+ : `${idPfx}purchase-origin-${origin.refId ?? i}-${origin.stockInLineId}`,
+ kind: "PURCHASE",
+ timestamp: origin.receiptDate,
+ sortKey: parseSortKey(origin.receiptDate, seq++),
+ title: labels.nodePurchase,
+ subtitle: [supplierLabel, data.lot.itemCode].filter(Boolean).join(" · ") || "—",
+ qty: origin.acceptedQty,
+ uom: stockUom,
+ refType: "PO",
+ refCode,
+ refId: origin.refId,
+ docLinkKind: "po",
+ categoryLabel: labels.categoryPurchase,
+ details: [
+ field(labels.detailSupplier, supplierLabel || "—"),
+ field(labels.detailOrderQty, formatQty(origin.acceptedQty, stockUom)),
+ field(labels.detailPurchaseOrderNo, refCode, {
+ linkKind: "po",
+ linkCode: refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailTime, origin.receiptDate),
+ ],
+ });
+ }
+
+ nodes.push({
+ id: `${idPfx}origin-${i}-${origin.stockInLineId}`,
+ kind: "RECEIPT",
+ timestamp: origin.receiptDate,
+ sortKey: parseSortKey(origin.receiptDate, seq++),
+ title: labels.nodeReceipt,
+ subtitle: [refCode, supplierLabel].filter(Boolean).join(" · ") || "—",
+ qty: origin.acceptedQty,
+ uom: stockUom,
+ refType: origin.type,
+ refCode,
+ refId: origin.refId,
+ docLinkKind: linkKind,
+ warehouseCode: defaultWh,
+ categoryLabel: categoryForKind("RECEIPT", labels),
+ details: detailsOf(
+ field(labels.detailPurchaseOrderNo, refCode, {
+ linkKind,
+ linkCode: refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
+ field(labels.detailTime, origin.receiptDate),
+ ),
+ });
+ return;
+ }
+
+ if (originType === "JO") {
+ const refCode = origin.refCode?.trim();
+ if (!refCode) return;
+ const joKey = `${refCode}|${origin.refId ?? 0}`;
+ coveredJoInbound.add(joKey);
+ const eventTimestamp = resolveJoCreatedTimestamp(data, refCode, origin.receiptDate);
+ nodes.push({
+ id: `${idPfx}origin-${i}-${origin.stockInLineId}`,
+ kind: "JO_CREATED",
+ timestamp: eventTimestamp,
+ sortKey: parseSortKey(eventTimestamp, seq++),
+ title: labels.nodeJoCreated,
+ subtitle: refCode,
+ qty: origin.acceptedQty,
+ uom: stockUom,
+ refType: origin.type,
+ refCode,
+ refId: origin.refId,
+ docLinkKind: linkKind,
+ warehouseCode: defaultWh,
+ categoryLabel: categoryForKind("JO_CREATED", labels),
+ details: detailsOf(
+ field(labels.detailType, labels.nodeJoCreated),
+ field(labels.jobOrder, refCode, {
+ linkKind,
+ linkCode: refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
+ field(labels.detailTime, eventTimestamp),
+ ),
+ });
+ }
+ });
+
+ return { nodes, nextSeq: seq };
+};
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 */
+export const buildTraceGraphNodes = (
+ data: ItemLotTraceResponse,
+ labels: TraceGraphDetailLabels,
+ scope?: TraceGraphBuildScope,
+): TraceGraphNode[] => {
+ const nodes: TraceGraphNode[] = [];
+ let seq = 0;
+ const stockUom = data.lot.uom;
+ const idPfx = scopePrefix(scope);
+ const lb = scope?.locationBlockKeys ?? false;
+ const primaryWh =
+ data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined;
+ const defaultScope: TraceGraphBuildScope | undefined = scope
+ ? scope
+ : primaryWh
+ ? { defaultWarehouseCode: primaryWh, inventoryLotId: data.lot.inventoryLotId }
+ : { inventoryLotId: data.lot.inventoryLotId };
+ const mergedMultiLocation = defaultScope?.mergedMultiLocation ?? false;
+
+ const existingPurchaseCodes = new Set();
+ const coveredPoInbound = new Set();
+ const coveredJoInbound = new Set();
+
+ (data.purchaseEvents ?? []).forEach((purchase, i) => {
+ const purchaseUnit = purchase.purchaseUnit;
+ const supplierLabel = [purchase.supplierCode, purchase.supplierName].filter(Boolean).join(" ");
+ const poCode = purchase.purchaseOrderCode?.trim();
+ if (poCode) existingPurchaseCodes.add(poCode);
+ const subtitle =
+ [supplierLabel, purchase.itemCode, purchase.itemName].filter(Boolean).join(" · ") || "—";
+ const meta = [supplierLabel].filter(Boolean).join(" · ");
+ nodes.push({
+ id: `${idPfx}purchase-${i}-${purchase.purchaseOrderLineId}`,
+ kind: "PURCHASE",
+ timestamp: purchase.orderDate,
+ sortKey: parseSortKey(purchase.orderDate, seq++),
+ title: labels.nodePurchase,
+ subtitle,
+ qty: purchase.orderQty,
+ uom: purchaseUnit,
+ meta: meta || undefined,
+ refType: "PO",
+ refCode: purchase.purchaseOrderCode,
+ refId: purchase.purchaseOrderId,
+ docLinkKind: "po",
+ categoryLabel: labels.categoryPurchase,
+ details: [
+ field(labels.detailItemCode, purchase.itemCode),
+ field(labels.detailItemName, purchase.itemName),
+ field(labels.detailSupplier, supplierLabel),
+ field(labels.detailOrderQty, formatQty(purchase.orderQty, purchaseUnit)),
+ field(labels.detailPurchaseUnit, purchase.purchaseUnit),
+ field(labels.detailPurchaseOrderNo, purchase.purchaseOrderCode, {
+ linkKind: "po",
+ linkCode: purchase.purchaseOrderCode,
+ linkId: purchase.purchaseOrderId,
+ }),
+ field(labels.detailTime, purchase.orderDate),
+ ],
+ });
+ });
+
+ const originBuilt = buildInboundOriginNodes(
+ data,
+ labels,
+ stockUom,
+ seq,
+ defaultScope,
+ existingPurchaseCodes,
+ coveredPoInbound,
+ coveredJoInbound,
+ );
+ nodes.push(...originBuilt.nodes);
+ seq = originBuilt.nextSeq;
+
+ const coveredDoMovementKeys = buildCoveredDoMovementKeys(data);
+ const pickOrderTargetDateMap = buildPickOrderTargetDateMap(data);
+ const doMetaByPickCode = new Map<
+ string,
+ {
+ isExtra: boolean;
+ isReplenish: boolean;
+ qtyChanged: boolean;
+ consoCode?: string;
+ }
+ >();
+ (data.doDeliveries ?? []).forEach((d) => {
+ const code = d.pickOrderCode?.trim();
+ if (!code) return;
+ const chipFlags = resolveDoOutboundChipFlags({
+ isExtra: d.isExtra,
+ isReplenish: d.isReplenish,
+ ticketNo: d.ticketNo,
+ consoCode: d.consoCode,
+ releaseType: d.releaseType,
+ deliveryOrderPickOrderId: d.deliveryOrderPickOrderId,
+ relationshipId: d.relationshipId,
+ });
+ doMetaByPickCode.set(code, {
+ isExtra: chipFlags.isExtra,
+ isReplenish: chipFlags.isReplenish,
+ qtyChanged: d.qtyChanged === true,
+ consoCode: d.consoCode?.trim() || code,
+ });
+ });
+
+ data.movements.forEach((m, i) => {
+ const refType = norm(m.refType);
+ if (MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS.has(refType)) return;
+ if (isStockTakeLedgerMovement(m.movementType)) return;
+ if (
+ refType === "DO" &&
+ coveredDoMovementKeys.has(doMovementDedupKey(m.refCode, m.timestamp, m.qty))
+ ) {
+ return;
+ }
+ if (refType === "RETURN" && (data.returnEvents?.length ?? 0) > 0) return;
+
+ if (m.direction === "IN" && refType === "PO") {
+ if (coveredPoInbound.has(poInboundKey(m.refCode, m.refId, m.qty))) return;
+ coveredPoInbound.add(poInboundKey(m.refCode, m.refId, m.qty));
+ }
+ if (m.direction === "IN" && refType === "JO") {
+ const joKey = `${(m.refCode ?? "").trim()}|${m.refId ?? 0}`;
+ if (coveredJoInbound.has(joKey)) return;
+ coveredJoInbound.add(joKey);
+ }
+
+ const isDoOut = refType === "DO" && m.direction !== "IN";
+ const isJoOut = refType === "JO_PICK" && m.direction !== "IN";
+ const isPoOut = refType === "PO_PICK" && m.direction !== "IN";
+ const kind: TraceGraphNodeKind =
+ m.direction === "IN"
+ ? refType === "PO"
+ ? "RECEIPT"
+ : refType === "JO"
+ ? "JO_CREATED"
+ : "IN"
+ : isDoOut
+ ? "DO_OUT"
+ : isJoOut
+ ? "JO_OUT"
+ : isPoOut
+ ? "PO_OUT"
+ : "OUT";
+ const eventTimestamp =
+ kind === "JO_CREATED"
+ ? resolveJoCreatedTimestamp(data, m.refCode, m.timestamp)
+ : m.timestamp;
+ const title =
+ kind === "RECEIPT"
+ ? labels.nodeReceipt
+ : kind === "JO_CREATED"
+ ? labels.nodeJoCreated
+ : kind === "DO_OUT"
+ ? `${labels.nodeDoOut} · ${m.refCode || "—"}`
+ : kind === "JO_OUT"
+ ? `${labels.nodeJoOut} · ${m.refCode || "—"}`
+ : kind === "PO_OUT"
+ ? `${labels.nodePoOut} · ${m.refCode || "—"}`
+ : `${labels.tr.refType(m.refType)} · ${labels.tr.movementType(m.movementType)}`;
+ const subtitle =
+ kind === "DO_OUT" || kind === "JO_OUT" || kind === "PO_OUT"
+ ? ""
+ : [m.refCode, m.warehouseCode].filter(Boolean).join(" · ") || "—";
+ const meta = [m.handledBy, m.remarks].filter(Boolean).join(" · ");
+ const linkKind = isDoOut || isJoOut || isPoOut ? "pick" : docLinkFromRefType(m.refType);
+ const receiptDetails =
+ kind === "RECEIPT"
+ ? detailsOf(
+ field(labels.detailPurchaseOrderNo, m.refCode, {
+ linkKind,
+ linkCode: m.refCode,
+ linkId: m.refId,
+ }),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, m.timestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ )
+ : kind === "DO_OUT"
+ ? detailsOf(
+ field(labels.detailType, labels.nodeDoOut),
+ field(labels.pickOrder, m.refCode, {
+ linkKind: "pick",
+ linkCode: m.refCode,
+ linkId: m.refId,
+ consoCode: m.refCode,
+ }),
+ field(labels.detailWarehouse, m.warehouseCode),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, m.timestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ )
+ : kind === "JO_OUT"
+ ? detailsOf(
+ field(labels.detailType, labels.nodeJoOut),
+ field(labels.pickOrder, m.refCode, {
+ linkKind: "jodetail",
+ linkCode: m.refCode,
+ linkId: m.refId,
+ consoCode: m.refCode,
+ linkTargetDate: resolvePickOrderTargetDate(
+ pickOrderTargetDateMap,
+ m.refCode,
+ m.timestamp,
+ ),
+ }),
+ field(
+ labels.detailPickTargetDate,
+ resolvePickOrderTargetDate(pickOrderTargetDateMap, m.refCode, m.timestamp),
+ ),
+ field(labels.detailWarehouse, m.warehouseCode),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus, {
+ always: true,
+ }),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, m.timestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ )
+ : kind === "PO_OUT"
+ ? detailsOf(
+ field(labels.detailType, labels.nodePoOut),
+ field(labels.pickOrder, m.refCode, {
+ linkKind: "pick",
+ linkCode: m.refCode,
+ linkId: m.refId,
+ consoCode: m.refCode,
+ }),
+ field(labels.detailWarehouse, m.warehouseCode),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, m.timestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ )
+ : detailsOf(
+ field(
+ labels.detailType,
+ kind === "JO_CREATED" ? labels.nodeJoCreated : labels.tr.movementType(m.movementType),
+ ),
+ field(labels.detailDirection, labels.tr.direction(m.direction)),
+ field(labels.detailSourceDoc, m.refCode, {
+ linkKind,
+ linkCode: m.refCode,
+ linkId: m.refId,
+ }),
+ field(labels.detailWarehouse, m.warehouseCode),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, eventTimestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ );
+ const pickMeta = isDoOut ? doMetaByPickCode.get(m.refCode?.trim() ?? "") : undefined;
+ const delivery = isDoOut
+ ? (data.doDeliveries ?? []).find((d) => d.pickOrderCode?.trim() === m.refCode?.trim())
+ : undefined;
+ const docLink = isDoOut
+ ? resolveDoOutboundDocLink({
+ pickOrderCode: delivery?.pickOrderCode || m.refCode,
+ pickOrderId: delivery?.pickOrderId ?? m.refId,
+ deliveryOrderCode: delivery?.deliveryOrderCode,
+ ticketNo: delivery?.ticketNo,
+ outboundTicketNo: m.outboundTicketNo,
+ consoCode: delivery?.consoCode || m.pickConsoCode,
+ timestamp: m.timestamp,
+ deliveryOrderPickOrderId:
+ delivery?.deliveryOrderPickOrderId ?? m.deliveryOrderPickOrderId,
+ })
+ : null;
+ const joPickTargetDate = isJoOut
+ ? resolvePickOrderTargetDate(pickOrderTargetDateMap, m.refCode, m.timestamp)
+ : undefined;
+ const joDocLink = isJoOut
+ ? resolveJoPickDocLink({
+ pickOrderCode: m.refCode,
+ pickOrderId: m.refId,
+ timestamp: m.timestamp,
+ targetDate: joPickTargetDate,
+ })
+ : null;
+ const movementChip = isDoOut
+ ? resolveDoOutboundChipFlags({
+ isExtra: pickMeta ? undefined : m.doOutboundIsExtra,
+ isReplenish: pickMeta ? undefined : m.doOutboundIsReplenish,
+ ticketNo: pickMeta ? undefined : m.outboundTicketNo,
+ consoCode: pickMeta ? undefined : (m.pickConsoCode || m.refCode),
+ deliveryOrderPickOrderId: pickMeta
+ ? undefined
+ : m.deliveryOrderPickOrderId,
+ relationshipId: pickMeta ? undefined : m.relationshipId,
+ })
+ : null;
+ const chipFlags = pickMeta
+ ? resolveDoOutboundChipFlags({
+ isExtra: pickMeta.isExtra,
+ isReplenish: pickMeta.isReplenish,
+ consoCode: pickMeta.consoCode,
+ })
+ : movementChip;
+ nodes.push({
+ id: lb
+ ? `${idPfx}mov-${i}-${m.refCode}`
+ : `${idPfx}mov-${i}-${m.refCode}-${eventTimestamp}`,
+ kind,
+ timestamp: eventTimestamp,
+ sortKey: parseSortKey(eventTimestamp, seq++),
+ title,
+ subtitle,
+ qty: m.qty,
+ uom: stockUom,
+ meta: meta || undefined,
+ refType: m.refType,
+ refCode: isDoOut ? (docLink?.displayCode ?? m.refCode) : m.refCode,
+ refId: m.refId,
+ docLinkKind: isDoOut ? docLink?.kind : isJoOut ? joDocLink?.kind : linkKind,
+ docLinkTicketNo: docLink?.ticketNo,
+ docLinkTargetDate: isDoOut ? docLink?.targetDate : joDocLink?.targetDate,
+ consoCode: isDoOut
+ ? (docLink?.consoCode ?? pickMeta?.consoCode ?? (m.pickConsoCode?.trim() || m.refCode))
+ : isJoOut || isPoOut
+ ? (m.pickConsoCode?.trim() || m.refCode?.trim() || undefined)
+ : undefined,
+ doOutboundIsExtra: chipFlags?.isExtra,
+ doOutboundIsReplenish: chipFlags?.isReplenish,
+ doOutboundQtyChanged: isDoOut
+ ? (pickMeta?.qtyChanged ?? m.doOutboundQtyChanged === true)
+ : undefined,
+ warehouseCode: m.warehouseCode?.trim() || undefined,
+ categoryLabel: categoryForKind(kind, labels),
+ ...(isJoOut
+ ? pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus, { always: true })
+ : {}),
+ details: receiptDetails,
+ });
+ });
+
+ const qcBuilt = buildQcNodes(data.qcResults, labels, stockUom, seq, scope);
+ nodes.push(...qcBuilt.nodes);
+ seq = qcBuilt.nextSeq;
+
+ const coveredPutawaySilIds = new Set();
+ const putawayLabels = {
+ nodePutaway: labels.nodePutaway,
+ nodePutawayTransfer: labels.nodePutawayTransfer,
+ putawayTransferDetail: labels.putawayTransferDetail,
+ };
+ (data.putawayEvents ?? []).forEach((putaway, i) => {
+ const matchedTransfer = isTransferInboundPutaway(putaway.refType)
+ ? matchTransferForInboundPutaway(putaway, data.transfers ?? [])
+ : undefined;
+ if (isTransferInboundPutaway(putaway.refType)) {
+ // Represented by TRANSFER cards (one per TR-*); skip destination putaway cards.
+ if (putaway.stockInLineId != null) {
+ coveredPutawaySilIds.add(putaway.stockInLineId);
+ }
+ return;
+ }
+ const linkKind = docLinkFromRefType(putaway.refType);
+ const pres = resolvePutawayPresentation(labels.tr, putawayLabels, putaway.status, putaway.refType);
+ const meta = [pres.chipLabel, putaway.handledBy, putaway.warehouseCode].filter(Boolean).join(" · ");
+ nodes.push({
+ id: lb
+ ? `${idPfx}putaway-${i}-${putaway.inventoryLotLineId}`
+ : `${idPfx}putaway-${i}-${putaway.inventoryLotLineId}-${putaway.timestamp}`,
+ kind: "PUTAWAY",
+ timestamp: putaway.timestamp,
+ sortKey: parseSortKey(putaway.timestamp, seq++),
+ title: pres.title,
+ subtitle: [pres.chipLabel, putaway.refCode, putaway.warehouseCode].filter(Boolean).join(" · ") || "—",
+ qty: putaway.qty,
+ uom: stockUom,
+ meta: meta || undefined,
+ refType: putaway.refType,
+ refCode: putaway.refCode,
+ refId: putaway.refId,
+ warehouseCode: putaway.warehouseCode,
+ transferFromWarehouse: matchedTransfer?.fromWarehouse ?? undefined,
+ transferToWarehouse: matchedTransfer?.toWarehouse ?? undefined,
+ docLinkKind: linkKind,
+ putawayStatusLabel: pres.chipLabel,
+ putawayStatusDetail: pres.statusDetail,
+ categoryLabel: labels.categoryPutaway,
+ details: [
+ field(labels.detailStatus, pres.statusDetail),
+ field(
+ isTransferInboundPutaway(putaway.refType)
+ ? labels.detailInboundSiNo
+ : normRefType(putaway.refType) === "PO"
+ ? labels.detailPurchaseOrderNo
+ : normRefType(putaway.refType) === "JO"
+ ? labels.jobOrder
+ : labels.detailSourceDoc,
+ putaway.refCode,
+ {
+ linkKind,
+ linkCode: putaway.refCode,
+ linkId: putaway.refId,
+ }),
+ ...(matchedTransfer
+ ? [
+ field(labels.detailFrom, matchedTransfer.fromWarehouse),
+ field(labels.detailTo, matchedTransfer.toWarehouse),
+ ]
+ : []),
+ field(labels.detailPutawayBin, putaway.warehouseCode),
+ field(labels.detailQty, formatQty(putaway.qty, stockUom)),
+ field(labels.detailHandler, putaway.handledBy),
+ field(labels.detailTime, putaway.timestamp),
+ ],
+ });
+ if (putaway.stockInLineId != null) {
+ coveredPutawaySilIds.add(putaway.stockInLineId);
+ }
+ });
+
+ (data.origins ?? []).forEach((origin, i) => {
+ if (coveredPutawaySilIds.has(origin.stockInLineId)) return;
+ if (!isPendingPutawayOriginStatus(origin.status)) return;
+ const originType = norm(origin.type);
+ if (!["PO", "JO", "TKE", "STOCK_IN"].includes(originType)) return;
+ if (isTransferInboundPutaway(origin.type)) return;
+ const pres = resolvePutawayPresentation(labels.tr, putawayLabels, origin.status, origin.type);
+ const linkKind = docLinkFromRefType(origin.type);
+ nodes.push({
+ id: `putaway-pending-${origin.stockInLineId}-${i}`,
+ kind: "PUTAWAY",
+ timestamp: origin.receiptDate,
+ sortKey: parseSortKey(origin.receiptDate, seq++),
+ title: pres.title,
+ subtitle: [pres.chipLabel, origin.refCode].filter(Boolean).join(" · ") || "—",
+ qty: origin.acceptedQty,
+ uom: stockUom,
+ refType: origin.type,
+ refCode: origin.refCode,
+ refId: origin.refId,
+ docLinkKind: linkKind,
+ putawayStatusLabel: pres.chipLabel,
+ putawayStatusDetail: pres.statusDetail,
+ categoryLabel: labels.categoryPutaway,
+ details: [
+ field(labels.detailStatus, pres.statusDetail),
+ field(
+ originType === "PO"
+ ? labels.detailPurchaseOrderNo
+ : originType === "JO"
+ ? labels.jobOrder
+ : labels.detailSourceDoc,
+ origin.refCode,
+ {
+ linkKind,
+ linkCode: origin.refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
+ field(labels.detailTime, origin.receiptDate),
+ ],
+ });
+ });
+
+ data.stockTakeEvents.forEach((e, i) => {
+ const roundLabel = formatStockTakeRoundLabel(e);
+ const detail = e.recordDetail ?? undefined;
+ const bookQty = resolveStockTakeBookQty(detail, e.beforeQty);
+ const acceptedQty = resolveStockTakeAcceptedQty(detail, e.afterQty);
+ const varianceQty =
+ detail?.varianceQty != null
+ ? Number(detail.varianceQty)
+ : acceptedQty != null && e.varianceQty != null
+ ? Number(e.varianceQty)
+ : null;
+ const meta = [e.approver, roundLabel, e.stockTakeSection].filter(Boolean).join(" · ");
+ nodes.push({
+ id: lb
+ ? `${idPfx}st-${i}-${e.stockTakeCode}`
+ : `${idPfx}st-${i}-${e.stockTakeCode}-${e.timestamp}`,
+ kind: "STOCK_TAKE",
+ timestamp: e.timestamp,
+ sortKey: parseSortKey(e.timestamp, seq++),
+ title: labels.nodeStockTake,
+ subtitle: stockTakeEventSubtitle(e),
+ qty: acceptedQty ?? undefined,
+ uom: stockUom,
+ meta: meta || undefined,
+ refCode: e.stockTakeCode,
+ warehouseCode: e.warehouseCode?.trim() || undefined,
+ traceLotNo: e.lotNo?.trim() || data.lot.lotNo?.trim() || undefined,
+ categoryLabel: labels.categoryStockTake,
+ stockTakeRecordDetail: detail,
+ stockTakeVarianceQty: varianceQty,
+ details: [
+ field(labels.detailStockTakeCode, e.stockTakeCode),
+ fieldIf(labels.detailStockTakeRound, roundLabel || undefined),
+ fieldIf(labels.detailStockTakeSection, e.stockTakeSection),
+ fieldIf(labels.detailLocation, e.warehouseCode),
+ fieldIf(labels.detailLot, e.lotNo || data.lot.lotNo),
+ fieldIf(labels.detailItemCode, e.itemCode),
+ fieldIf(labels.detailItemName, e.itemName),
+ field(labels.detailBefore, formatQty(bookQty, stockUom)),
+ fieldIf(
+ labels.detailStockTakeFirstCount,
+ detail?.pickerFirstQty != null
+ ? formatQty(detail.pickerFirstQty, stockUom)
+ : undefined,
+ ),
+ fieldIf(
+ labels.detailStockTakeSecondCount,
+ detail?.pickerSecondQty != null
+ ? formatQty(detail.pickerSecondQty, stockUom)
+ : undefined,
+ ),
+ fieldIf(
+ labels.detailStockTakeApproverCount,
+ detail?.lastSelect === 3 && detail.approverQty != null
+ ? formatQty(detail.approverQty, stockUom)
+ : undefined,
+ ),
+ field(labels.detailAfter, formatQty(acceptedQty, stockUom)),
+ field(labels.detailVariance, formatQty(varianceQty, stockUom)),
+ fieldIf(labels.detailStockTaker, detail?.stockTakerName),
+ fieldIf(labels.detailApprover, detail?.approverName),
+ // Fallback when record detail is absent: keep legacy single handler field.
+ !detail?.stockTakerName && !detail?.approverName
+ ? field(labels.detailHandler, e.approver)
+ : null,
+ field(labels.detailTime, e.timestamp),
+ ].filter((row): row is TraceGraphDetailField => Boolean(row)),
+ });
+ });
+
+ data.adjustments.forEach((a, i) => {
+ if (norm(a.adjustmentType) === "TKE" && (data.stockTakeEvents?.length ?? 0) > 0) return;
+ const meta = [a.handledBy, a.reason].filter(Boolean).join(" · ");
+ const adjustmentWh =
+ a.warehouseCode?.trim() || defaultScope?.defaultWarehouseCode;
+ nodes.push({
+ id: lb
+ ? `${idPfx}adj-${i}-${a.refCode}-${a.timestamp}`
+ : `${idPfx}adj-${i}-${a.refCode}-${a.timestamp}-${a.direction}`,
+ kind: "ADJUSTMENT",
+ timestamp: a.timestamp,
+ sortKey: parseSortKey(a.timestamp, seq++),
+ title: `${labels.nodeAdjustment} (${labels.tr.direction(a.direction)})`,
+ subtitle: [labels.tr.adjustmentType(a.adjustmentType), a.refCode].filter(Boolean).join(" · ") || "—",
+ qty: a.qty,
+ uom: stockUom,
+ adjustmentDirection: a.direction,
+ meta: meta || undefined,
+ refCode: a.refCode,
+ warehouseCode: adjustmentWh,
+ categoryLabel: labels.categoryAdjustment,
+ details: detailsOf(
+ field(labels.detailVariance, formatSignedQty(a.qty, a.direction, stockUom)),
+ field(labels.detailAdjustmentRef, a.refCode),
+ fieldIf(labels.detailRemarks, a.reason),
+ ...(adjustmentWh ? [field(labels.detailWarehouse, adjustmentWh)] : []),
+ field(labels.detailHandler, a.handledBy),
+ field(labels.detailTime, a.timestamp),
+ ),
+ });
+ });
+
+ const normWh = (w?: string | null) => (w ?? "").trim().toUpperCase();
+ data.transfers.forEach((tr, i) => {
+ if (!shouldEmitTransferForScope(defaultScope?.locationBlockKeys)) {
+ return;
+ }
+ const inboundSi = (data.putawayEvents ?? []).find(
+ (p) =>
+ isTransferInboundPutaway(p.refType) &&
+ normWh(p.warehouseCode) === normWh(tr.toWarehouse),
+ );
+ nodes.push({
+ id: lb
+ ? `${idPfx}tr-${i}-${tr.transferCode}`
+ : `${idPfx}tr-${i}-${tr.transferCode}-${tr.timestamp}`,
+ kind: "TRANSFER",
+ timestamp: tr.timestamp,
+ sortKey: parseSortKey(tr.timestamp, seq++),
+ title: labels.nodeTransfer,
+ subtitle: "",
+ qty: tr.qty,
+ uom: stockUom,
+ refCode: tr.transferCode,
+ transferFromWarehouse: tr.fromWarehouse,
+ transferToWarehouse: tr.toWarehouse,
+ categoryLabel: labels.categoryTransfer,
+ details: [
+ field(labels.detailFrom, tr.fromWarehouse),
+ field(labels.detailTo, tr.toWarehouse),
+ field(labels.detailQty, formatQty(tr.qty, stockUom)),
+ ...(inboundSi?.refCode
+ ? [field(labels.detailInboundSiNo, inboundSi.refCode)]
+ : []),
+ field(labels.detailTime, tr.timestamp),
+ ],
+ });
+ });
+
+ const totalAvailable = data.warehouseLines.reduce((s, w) => s + (w.availableQty ?? 0), 0);
+ const expiry = data.lot.expiryDate;
+ if (expiry && dayjs(expiry).isValid() && dayjs(expiry).isBefore(dayjs(), "day")) {
+ nodes.push({
+ id: `${idPfx}terminal-expired-${data.lot.lotNo}`,
+ kind: "EXPIRED",
+ timestamp: expiry,
+ sortKey: parseExpirySortKey(expiry, seq + 1_000_000),
+ title: labels.nodeExpired,
+ subtitle: expiry,
+ categoryLabel: labels.categoryTerminal,
+ details: [
+ field(labels.expiryDate, expiry),
+ field(labels.totalAvailable, formatQty(totalAvailable, stockUom)),
+ field(labels.detailStatus, labels.nodeExpired),
+ ],
+ });
+ }
+ if (
+ data.warehouseLines.length > 0 &&
+ shouldEmitDepletedForScope(
+ totalAvailable,
+ defaultScope?.defaultWarehouseCode,
+ data.transfers ?? [],
+ mergedMultiLocation,
+ )
+ ) {
+ const lastTs =
+ data.movements[0]?.timestamp ??
+ data.outboundUsage[0]?.timestamp ??
+ data.lot.stockInDate;
+ nodes.push({
+ id: `${idPfx}terminal-depleted-${data.lot.lotNo}`,
+ kind: "DEPLETED",
+ timestamp: lastTs,
+ sortKey: parseSortKey(lastTs, seq + 2_000_000),
+ title: labels.nodeDepleted,
+ subtitle: `${labels.totalAvailable}: ${formatQty(0, stockUom)}`,
+ categoryLabel: labels.categoryTerminal,
+ details: [
+ field(labels.totalAvailable, formatQty(0, stockUom)),
+ field(labels.detailTime, lastTs),
+ field(labels.detailStatus, labels.nodeDepleted),
+ ],
+ });
+ }
+
+ return nodes
+ .map((n) => withNodeScope(n, defaultScope))
+ .sort((a, b) => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ return a.id.localeCompare(b.id);
+ });
+};
+
+export { categoryForKind };
diff --git a/src/components/ItemTracing/compileTraceGraph.ts b/src/components/ItemTracing/compileTraceGraph.ts
new file mode 100644
index 0000000..60ef281
--- /dev/null
+++ b/src/components/ItemTracing/compileTraceGraph.ts
@@ -0,0 +1,33 @@
+import { ItemLotTraceResponse } from "@/app/api/itemTracing";
+import {
+ buildTraceGraphLayout,
+ TraceGraphLayout,
+ TraceGraphLayoutNode,
+} from "./traceGraphLayout";
+import { resolveTraceFlowEdgePairs } from "./traceGraphSemantics";
+import type { TraceGraphCompileLabels } from "./traceGraphLabels";
+
+export type TraceFlowEdgePair = { fromId: string; toId: string };
+
+export type CompiledTraceGraph = {
+ layout: TraceGraphLayout;
+ edgePairs: TraceFlowEdgePair[];
+ nodes: TraceGraphLayoutNode[];
+};
+
+export const compileTraceGraph = (
+ data: ItemLotTraceResponse,
+ labels: TraceGraphCompileLabels,
+): CompiledTraceGraph => {
+ const layout = buildTraceGraphLayout(data, labels);
+ const backendEdges = data.traceGraph?.edges?.map((e) => ({
+ fromKey: e.fromKey,
+ toKey: e.toKey,
+ }));
+ const edgePairs = resolveTraceFlowEdgePairs(layout.nodes, layout.phaseOrder, backendEdges);
+ return {
+ layout,
+ edgePairs,
+ nodes: layout.nodes,
+ };
+};
diff --git a/src/components/ItemTracing/exportItemLotTraceXlsx.ts b/src/components/ItemTracing/exportItemLotTraceXlsx.ts
new file mode 100644
index 0000000..01c2c7e
--- /dev/null
+++ b/src/components/ItemTracing/exportItemLotTraceXlsx.ts
@@ -0,0 +1,905 @@
+import type { TFunction } from "i18next";
+import type { ItemLotTraceResponse } from "@/app/api/itemTracing";
+import {
+ exportMultiSheetToXlsx,
+ type MultiSheetSpec,
+} from "@/app/(main)/chart/_components/exportChartToXlsx";
+import type { CompiledTraceGraph } from "./compileTraceGraph";
+import {
+ mergeScopedAdjustments,
+ mergeScopedDoDeliveries,
+ mergeScopedFailEvents,
+ mergeScopedOpenMovements,
+ mergeScopedOrigins,
+ mergeScopedOutboundUsage,
+ mergeScopedPurchaseEvents,
+ mergeScopedPutawayEvents,
+ mergeScopedQcResults,
+ mergeScopedReplenishmentEvents,
+ mergeScopedReturnEvents,
+ mergeScopedStockTakeEvents,
+ mergeScopedTransfers,
+} from "./mergeLocationScopedData";
+import {
+ resolveStockTakeAcceptedQty,
+ resolveStockTakeBookQty,
+} from "./traceStockTakeUtils";
+import {
+ createTraceLabelTranslator,
+ type TraceLabelTranslator,
+} from "./traceLabelUtils";
+import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils";
+import { buildTracePresentationRows } from "./tracePresentationAdapter";
+import { isDoGroupChild } from "./traceDoGroupLayout";
+
+const NO_DATA = "(無資料 / No records)";
+
+const SHEET = {
+ summary: "摘要 Summary",
+ timeline: "生命週期時間軸 Timeline",
+ origins: "來源與採購 Origins",
+ qc: "品質檢驗 QC",
+ warehouse: "倉儲作業 Warehouse",
+ outbound: "出庫 Outbound",
+ stockTake: "盤點 Stock Take",
+ production: "生產與BOM Production",
+} as const;
+
+export type ItemLotTraceExportLabels = {
+ bomDirectionFg: string;
+ bomDirectionMaterial: string;
+ bomDirectionUnknown: string;
+};
+
+const orDash = (v: string | number | null | undefined): string | number => {
+ if (v == null) return "—";
+ if (typeof v === "string" && !v.trim()) return "—";
+ return v;
+};
+
+const bomDirectionLabel = (
+ direction: string,
+ labels: ItemLotTraceExportLabels,
+): string => {
+ const d = direction.trim().toUpperCase();
+ if (d === "FINISHED_GOOD") return labels.bomDirectionFg;
+ if (d === "MATERIAL") return labels.bomDirectionMaterial;
+ return labels.bomDirectionUnknown;
+};
+
+const ensureRows = (
+ rows: Record[],
+ emptyTemplate: Record,
+): Record[] => {
+ if (rows.length > 0) return rows;
+ const firstKey = Object.keys(emptyTemplate)[0];
+ return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA } : {}) }];
+};
+
+const todayYmd = (): string => {
+ const d = new Date();
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}${m}${day}`;
+};
+
+const safeFilenamePart = (s: string): string =>
+ s.replace(/[\\/:*?"<>|]/g, "_").trim() || "unknown";
+
+export const buildItemLotTraceFilename = (
+ data: ItemLotTraceResponse,
+): string => {
+ const item = safeFilenamePart(data.lot.itemCode || "item");
+ const lot = safeFilenamePart(data.lot.lotNo || "lot");
+ return `批號追溯_${item}_${lot}_${todayYmd()}`;
+};
+
+const summaryEmpty = (): Record => ({
+ "Field / 欄位": "",
+ "Value / 值": "",
+});
+
+const buildSummarySheet = (
+ data: ItemLotTraceResponse,
+ labels: ItemLotTraceExportLabels,
+ exportAt: string,
+): Record[] => {
+ const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data;
+ const totalAvailable = warehouseLines.reduce(
+ (s, w) => s + (w.availableQty ?? 0),
+ 0,
+ );
+ const rows: Record[] = [
+ { "Field / 欄位": "Lot No. / 批號", "Value / 值": orDash(lot.lotNo) },
+ { "Field / 欄位": "Item Code / 貨品編號", "Value / 值": orDash(lot.itemCode) },
+ { "Field / 欄位": "Item Name / 品名", "Value / 值": orDash(lot.itemName) },
+ { "Field / 欄位": "UOM / 單位", "Value / 值": orDash(lot.uom) },
+ { "Field / 欄位": "Expiry / 效期", "Value / 值": orDash(lot.expiryDate) },
+ {
+ "Field / 欄位": "Production Date / 生產日期",
+ "Value / 值": orDash(lot.productionDate),
+ },
+ {
+ "Field / 欄位": "Stock In Date / 入庫日期",
+ "Value / 值": orDash(lot.stockInDate),
+ },
+ {
+ "Field / 欄位": "Total Available / 總可用量",
+ "Value / 值": totalAvailable,
+ },
+ {
+ "Field / 欄位": "BOM Direction / BOM 方向",
+ "Value / 值": bomDirectionLabel(bomTrace.direction, labels),
+ },
+ ];
+
+ if (joPrelude) {
+ const jo = joPrelude.jobOrder;
+ rows.push(
+ {
+ "Field / 欄位": "Job Order / 工單",
+ "Value / 值": orDash(jo.jobOrderCode),
+ },
+ {
+ "Field / 欄位": "Plan Start / 計劃開工",
+ "Value / 值": orDash(jo.planStart),
+ },
+ { "Field / 欄位": "Planned Qty / 計劃產量", "Value / 值": jo.reqQty },
+ {
+ "Field / 欄位": "JO Status / 工單狀態",
+ "Value / 值": orDash(jo.status),
+ },
+ );
+ }
+
+ rows.push(
+ { "Field / 欄位": "Export Time / 匯出時間", "Value / 值": exportAt },
+
+ { "Field / 欄位": "", "Value / 值": "" },
+ {
+ "Field / 欄位": "— Warehouse Breakdown / 各倉庫存量 —",
+ "Value / 值": "",
+ },
+ );
+
+ warehouseLines.forEach((w) => {
+ rows.push({
+ "Field / 欄位": `Warehouse / 倉位: ${w.warehouseCode}`,
+ "Value / 值": `In ${w.inQty} · Out ${w.outQty} · Available ${w.availableQty} · ${w.status}`,
+ });
+ });
+
+ if (alternateLocations.length > 0) {
+ rows.push(
+ { "Field / 欄位": "", "Value / 值": "" },
+ {
+ "Field / 欄位": "— Alternate Locations / 其他倉位 —",
+ "Value / 值": "",
+ },
+ );
+ alternateLocations.forEach((loc) => {
+ rows.push({
+ "Field / 欄位": `Inventory Lot ${loc.inventoryLotId}`,
+ "Value / 值": `${loc.warehouseCode} · Available ${loc.availableQty}`,
+ });
+ });
+ }
+
+ return rows.length > 0 ? rows : [summaryEmpty()];
+};
+
+const timelineEmpty = (): Record => ({
+ "Date/Time / 時間": "",
+ "Phase / 階段": "",
+ "Event Type / 事件類型": "",
+ "Doc No. / 單號": "",
+ "Ref Type / 來源類型": "",
+ "Ref Type Code": "",
+ "Qty / 數量": "",
+ "UOM / 單位": "",
+ "Warehouse / 倉位": "",
+ "Handler / 經手人": "",
+ "Lot / 批號": "",
+ "Remarks / 備註": "",
+});
+
+const buildTimelineSheet = (
+ compiledGraph: CompiledTraceGraph,
+ t: TFunction,
+ tr: TraceLabelTranslator,
+): Record[] => {
+ const nodes = compiledGraph.layout.nodes
+ .filter(
+ (n) =>
+ n.kind !== "DO_GROUP" && n.kind !== "PICK_GROUP" && !isDoGroupChild(n),
+ )
+ .slice()
+ .sort((a, b) => {
+ const ta = a.timestamp ?? "";
+ const tb = b.timestamp ?? "";
+ if (ta !== tb) return ta.localeCompare(tb);
+ return a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex;
+ });
+
+ const rows = nodes.map((n) => {
+ const handlerDetail = n.details.find(
+ (d) =>
+ d.label.toLowerCase().includes("handler") ||
+ d.label.includes("經手") ||
+ d.label.includes("核准") ||
+ d.label.includes("盤點"),
+ );
+ return {
+ "Date/Time / 時間": orDash(n.timestamp),
+ "Phase / 階段": t(phaseLabelKey(n.phase)),
+ "Event Type / 事件類型": t(kindLabelKey(n.kind)),
+ "Doc No. / 單號": orDash(n.refCode),
+ "Ref Type / 來源類型": tr.refType(n.refType),
+ "Ref Type Code": orDash(n.refType),
+ "Qty / 數量": n.qty ?? "",
+ "UOM / 單位": orDash(n.uom),
+ "Warehouse / 倉位": orDash(n.warehouseCode),
+ "Handler / 經手人": orDash(
+ handlerDetail?.value ?? n.meta?.split(" · ")[0],
+ ),
+ "Lot / 批號": orDash(n.traceLotNo),
+ "Remarks / 備註": orDash(n.subtitle),
+ };
+ });
+ return ensureRows(rows, timelineEmpty());
+};
+
+const originsEmpty = (): Record => ({
+ "Section / 區塊": "",
+ "Warehouse / 倉位": "",
+ "Inventory Lot Id": "",
+ "Type / 類型": "",
+ "Type Code": "",
+ "Doc No. / 單號": "",
+ "Supplier Code / 供應商編號": "",
+ "Supplier / 供應商": "",
+ "DN No. / 送貨單號": "",
+ "Qty / 數量": "",
+ "Status / 狀態": "",
+ "Date / 日期": "",
+});
+
+const buildOriginsSheet = (
+ data: ItemLotTraceResponse,
+ tr: TraceLabelTranslator,
+): Record[] => {
+ const originRows = mergeScopedOrigins(data).map((o) => ({
+ "Section / 區塊": "Origin / 來源",
+ "Warehouse / 倉位": o.scopeWarehouseCode,
+ "Inventory Lot Id": o.inventoryLotId,
+ "Type / 類型": tr.refType(o.type),
+ "Type Code": orDash(o.type),
+ "Doc No. / 單號": orDash(o.refCode),
+ "Supplier Code / 供應商編號": orDash(o.supplierCode),
+ "Supplier / 供應商": orDash(o.supplierName),
+ "DN No. / 送貨單號": orDash(o.dnNo),
+ "Qty / 數量": o.acceptedQty,
+ "Status / 狀態": tr.stockInStatus(o.status),
+ "Date / 日期": orDash(o.receiptDate),
+ }));
+
+ const purchaseRows = mergeScopedPurchaseEvents(data).map((p) => ({
+ "Section / 區塊": "Purchase / 採購",
+ "Warehouse / 倉位": p.scopeWarehouseCode,
+ "Inventory Lot Id": p.inventoryLotId,
+ "Type / 類型": tr.refType("PO"),
+ "Type Code": "PO",
+ "Doc No. / 單號": orDash(p.purchaseOrderCode),
+ "Supplier Code / 供應商編號": orDash(p.supplierCode),
+ "Supplier / 供應商": orDash(p.supplierName),
+ "DN No. / 送貨單號": "",
+ "Qty / 數量": p.orderQty,
+ "Status / 狀態": orDash(p.status),
+ "Date / 日期": orDash(p.orderDate),
+ }));
+
+ return ensureRows([...originRows, ...purchaseRows], originsEmpty());
+};
+
+const qcEmpty = (): Record => ({
+ "Warehouse / 倉位": "",
+ "Inventory Lot Id": "",
+ "QC Result / 結果": "",
+ "QC Type / 品檢類型": "",
+ "QC Type Code": "",
+ "QC Item Code / 品檢項編號": "",
+ "QC Item / 品檢項": "",
+ "Accepted Qty / 抽樣數量": "",
+ "Fail Qty / 不良數量": "",
+ "Remarks / 備註": "",
+ "Handler / 經手人": "",
+ "Date/Time / 時間": "",
+ "Stock In Line Id": "",
+});
+
+const buildQcSheet = (
+ data: ItemLotTraceResponse,
+ tr: TraceLabelTranslator,
+): Record[] => {
+ const rows = mergeScopedQcResults(data).map((q) => ({
+ "Warehouse / 倉位": q.scopeWarehouseCode,
+ "Inventory Lot Id": q.inventoryLotId,
+ "QC Result / 結果": tr.qcPassed(q.qcPassed),
+ "QC Type / 品檢類型": tr.qcType(q.qcType),
+ "QC Type Code": orDash(q.qcType),
+ "QC Item Code / 品檢項編號": orDash(q.qcItemCode),
+ "QC Item / 品檢項": orDash(q.qcItemName || q.qcItemDescription),
+ "Accepted Qty / 抽樣數量": q.acceptedQty,
+ "Fail Qty / 不良數量": q.failQty,
+ "Remarks / 備註": orDash(q.remarks),
+ "Handler / 經手人": orDash(q.handledBy),
+ "Date/Time / 時間": orDash(q.created),
+ "Stock In Line Id": q.stockInLineId,
+ }));
+ return ensureRows(rows, qcEmpty());
+};
+
+const warehouseEmpty = (): Record => ({
+ "Section / 區塊": "",
+ "Warehouse / 倉位": "",
+ "Inventory Lot Id": "",
+ "Direction / 方向": "",
+ "Type / 類型": "",
+ "Type Code": "",
+ "Doc No. / 單號": "",
+ "From / 來源倉": "",
+ "To / 目標倉": "",
+ "Qty / 數量": "",
+ "Handler / 經手人": "",
+ "Status / 狀態": "",
+ "Remarks / 備註": "",
+ "Date/Time / 時間": "",
+});
+
+const buildWarehouseSheet = (
+ data: ItemLotTraceResponse,
+ tr: TraceLabelTranslator,
+): Record[] => {
+ const putaway = mergeScopedPutawayEvents(data).map((p) => ({
+ "Section / 區塊": "Putaway / 上架",
+ "Warehouse / 倉位": p.scopeWarehouseCode,
+ "Inventory Lot Id": p.inventoryLotId,
+ "Direction / 方向": "",
+ "Type / 類型": tr.refType(p.refType),
+ "Type Code": orDash(p.refType),
+ "Doc No. / 單號": orDash(p.refCode),
+ "From / 來源倉": "",
+ "To / 目標倉": orDash(p.warehouseCode),
+ "Qty / 數量": p.qty,
+ "Handler / 經手人": orDash(p.handledBy),
+ "Status / 狀態": orDash(p.status),
+ "Remarks / 備註": "",
+ "Date/Time / 時間": orDash(p.timestamp),
+ }));
+
+ const transfers = mergeScopedTransfers(data).map((trRow) => ({
+ "Section / 區塊": "Transfer / 轉倉",
+ "Warehouse / 倉位": trRow.scopeWarehouseCode,
+ "Inventory Lot Id": trRow.inventoryLotId,
+ "Direction / 方向": "",
+ "Type / 類型": tr.refType("TRANSFER"),
+ "Type Code": "TRANSFER",
+ "Doc No. / 單號": orDash(trRow.transferCode),
+ "From / 來源倉": orDash(trRow.fromWarehouse),
+ "To / 目標倉": orDash(trRow.toWarehouse),
+ "Qty / 數量": trRow.qty,
+ "Handler / 經手人": "",
+ "Status / 狀態": "",
+ "Remarks / 備註": "",
+ "Date/Time / 時間": orDash(trRow.timestamp),
+ }));
+
+ const adjustments = mergeScopedAdjustments(data).map((a) => ({
+ "Section / 區塊": "Adjustment / 庫存調整",
+ "Warehouse / 倉位": a.scopeWarehouseCode,
+ "Inventory Lot Id": a.inventoryLotId,
+ "Direction / 方向": tr.direction(a.direction),
+ "Type / 類型": tr.adjustmentType(a.adjustmentType),
+ "Type Code": orDash(a.adjustmentType),
+ "Doc No. / 單號": orDash(a.refCode),
+ "From / 來源倉": "",
+ "To / 目標倉": orDash(a.warehouseCode),
+ "Qty / 數量": a.qty,
+ "Handler / 經手人": orDash(a.handledBy),
+ "Status / 狀態": "",
+ "Remarks / 備註": orDash(a.reason),
+ "Date/Time / 時間": orDash(a.timestamp),
+ }));
+
+ const openMoves = mergeScopedOpenMovements(data).map((o) => ({
+ "Section / 區塊": "Opening Stock / 開倉入庫",
+ "Warehouse / 倉位": o.scopeWarehouseCode,
+ "Inventory Lot Id": o.inventoryLotId,
+ "Direction / 方向": tr.direction("IN"),
+ "Type / 類型": tr.refType("OPEN"),
+ "Type Code": "OPEN",
+ "Doc No. / 單號": orDash(o.refCode),
+ "From / 來源倉": "",
+ "To / 目標倉": orDash(o.warehouseCode),
+ "Qty / 數量": o.qty,
+ "Handler / 經手人": orDash(o.handledBy),
+ "Status / 狀態": "",
+ "Remarks / 備註": orDash(o.remarks),
+ "Date/Time / 時間": orDash(o.timestamp),
+ }));
+
+ return ensureRows(
+ [...putaway, ...transfers, ...adjustments, ...openMoves],
+ warehouseEmpty(),
+ );
+};
+
+const outboundEmpty = (): Record => ({
+ "Section / 區塊": "",
+ "Warehouse / 倉位": "",
+ "Inventory Lot Id": "",
+ "Usage Type / 類型": "",
+ "Usage Type Code": "",
+ "Doc No. / 單號": "",
+ "Pick Order / 提料單": "",
+ "Conso / 併單": "",
+ "Job Order / 工單": "",
+ "Delivery Order / 送貨單": "",
+ "Delivery Note / DN": "",
+ "Ticket / 票號": "",
+ "Shop / 門市": "",
+ "Qty / 數量": "",
+ "Handler / 經手人": "",
+ "Extra / 加單": "",
+ "Replenish / 補貨": "",
+ "Remarks / 備註": "",
+ "Date/Time / 時間": "",
+});
+
+const buildOutboundSheet = (
+ data: ItemLotTraceResponse,
+ tr: TraceLabelTranslator,
+): Record[] => {
+ const usage = mergeScopedOutboundUsage(data).map((u) => ({
+ "Section / 區塊": "Outbound Usage / 出庫使用",
+ "Warehouse / 倉位": u.scopeWarehouseCode,
+ "Inventory Lot Id": u.inventoryLotId,
+ "Usage Type / 類型": tr.usageType(u.usageType),
+ "Usage Type Code": orDash(u.usageType),
+ "Doc No. / 單號": orDash(u.deliveryOrderCode || u.pickOrderCode),
+ "Pick Order / 提料單": orDash(u.pickOrderCode),
+ "Conso / 併單": orDash(u.consoCode),
+ "Job Order / 工單": orDash(u.jobOrderCode),
+ "Delivery Order / 送貨單": orDash(u.deliveryOrderCode),
+ "Delivery Note / DN": "",
+ "Ticket / 票號": "",
+ "Shop / 門市": "",
+ "Qty / 數量": u.qty,
+ "Handler / 經手人": orDash(u.handler),
+ "Extra / 加單": "",
+ "Replenish / 補貨": "",
+ "Remarks / 備註": "",
+ "Date/Time / 時間": orDash(u.timestamp),
+ }));
+
+ const doRows = mergeScopedDoDeliveries(data).map((d) => ({
+ "Section / 區塊": "DO Delivery / 成品出倉",
+ "Warehouse / 倉位": d.scopeWarehouseCode,
+ "Inventory Lot Id": d.inventoryLotId,
+ "Usage Type / 類型": tr.usageType("FG_DELIVERY"),
+ "Usage Type Code": "FG_DELIVERY",
+ "Doc No. / 單號": orDash(d.deliveryOrderCode),
+ "Pick Order / 提料單": orDash(d.pickOrderCode),
+ "Conso / 併單": orDash(d.consoCode),
+ "Job Order / 工單": "",
+ "Delivery Order / 送貨單": orDash(d.deliveryOrderCode),
+ "Delivery Note / DN": orDash(d.deliveryNoteCode),
+ "Ticket / 票號": orDash(d.ticketNo),
+ "Shop / 門市": [d.shopCode, d.shopName].filter(Boolean).join(" · ") || "—",
+ "Qty / 數量": d.qty,
+ "Handler / 經手人": orDash(d.handler),
+ "Extra / 加單": d.isExtra ? "Y" : "",
+ "Replenish / 補貨": d.isReplenish ? "Y" : "",
+ "Remarks / 備註": "",
+ "Date/Time / 時間": orDash(d.timestamp),
+ }));
+
+ const replenish = mergeScopedReplenishmentEvents(data).map((r) => ({
+ "Section / 區塊": "Replenishment / 補貨",
+ "Warehouse / 倉位": r.scopeWarehouseCode,
+ "Inventory Lot Id": r.inventoryLotId,
+ "Usage Type / 類型": "Replenishment",
+ "Usage Type Code": "REPLENISH",
+ "Doc No. / 單號": orDash(r.replenishmentCode),
+ "Pick Order / 提料單": "",
+ "Conso / 併單": "",
+ "Job Order / 工單": "",
+ "Delivery Order / 送貨單": orDash(r.sourceDoCode),
+ "Delivery Note / DN": "",
+ "Ticket / 票號": "",
+ "Shop / 門市": [r.shopCode, r.shopName].filter(Boolean).join(" · ") || "—",
+ "Qty / 數量": r.replenishQty,
+ "Handler / 經手人": orDash(r.handler),
+ "Extra / 加單": "",
+ "Replenish / 補貨": "Y",
+ "Remarks / 備註": orDash(r.reason),
+ "Date/Time / 時間": orDash(r.timestamp),
+ }));
+
+ const returns = mergeScopedReturnEvents(data).map((r) => ({
+ "Section / 區塊": "Return / 退貨",
+ "Warehouse / 倉位": r.scopeWarehouseCode,
+ "Inventory Lot Id": r.inventoryLotId,
+ "Usage Type / 類型": tr.movementType(r.movementType),
+ "Usage Type Code": orDash(r.movementType),
+ "Doc No. / 單號": orDash(r.refCode),
+ "Pick Order / 提料單": "",
+ "Conso / 併單": "",
+ "Job Order / 工單": "",
+ "Delivery Order / 送貨單": "",
+ "Delivery Note / DN": "",
+ "Ticket / 票號": "",
+ "Shop / 門市": "",
+ "Qty / 數量": r.qty,
+ "Handler / 經手人": orDash(r.handler),
+ "Extra / 加單": "",
+ "Replenish / 補貨": "",
+ "Remarks / 備註": orDash(r.remarks),
+ "Date/Time / 時間": orDash(r.timestamp),
+ }));
+
+ const fails = mergeScopedFailEvents(data).map((f) => ({
+ "Section / 區塊": "Pick Failure / 揀貨異常",
+ "Warehouse / 倉位": f.scopeWarehouseCode,
+ "Inventory Lot Id": f.inventoryLotId,
+ "Usage Type / 類型": tr.failType(f.failType),
+ "Usage Type Code": orDash(f.failType),
+ "Doc No. / 單號": orDash(f.pickOrderCode),
+ "Pick Order / 提料單": orDash(f.pickOrderCode),
+ "Conso / 併單": "",
+ "Job Order / 工單": "",
+ "Delivery Order / 送貨單": "",
+ "Delivery Note / DN": "",
+ "Ticket / 票號": "",
+ "Shop / 門市": "",
+ "Qty / 數量": f.qty,
+ "Handler / 經手人": orDash(f.handlerName),
+ "Extra / 加單": "",
+ "Replenish / 補貨": "",
+ "Remarks / 備註": orDash(f.category),
+ "Date/Time / 時間": orDash(f.recordDate),
+ }));
+
+ return ensureRows(
+ [...usage, ...doRows, ...replenish, ...returns, ...fails],
+ outboundEmpty(),
+ );
+};
+
+const stockTakeEmpty = (): Record => ({
+ "Warehouse / 倉位": "",
+ "Inventory Lot Id": "",
+ "Stock Take Code / 盤點單號": "",
+ "Round / 輪次": "",
+ "Section / 區域": "",
+ "Lot / 批號": "",
+ "Book Qty / 帳面數量": "",
+ "Accepted Qty / 核准數量": "",
+ "Variance Qty / 差異數量": "",
+ "Approver / 核准人": "",
+ "Stock Taker / 初盤人": "",
+ "First Count Qty / 初盤數量": "",
+ "Second Count Qty / 複盤數量": "",
+ "Approver Count Qty / 覆盤數量": "",
+ "Record Status / 紀錄狀態": "",
+ "Date/Time / 時間": "",
+});
+
+const buildStockTakeSheet = (
+ data: ItemLotTraceResponse,
+): Record[] => {
+ const rows = mergeScopedStockTakeEvents(data).map((e) => {
+ const d = e.recordDetail;
+ return {
+ "Warehouse / 倉位": e.scopeWarehouseCode,
+ "Inventory Lot Id": e.inventoryLotId,
+ "Stock Take Code / 盤點單號": orDash(e.stockTakeCode),
+ "Round / 輪次": orDash(
+ e.stockTakeRoundName ??
+ (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : ""),
+ ),
+ "Section / 區域": orDash(e.stockTakeSection),
+ "Lot / 批號": orDash(e.lotNo),
+ "Book Qty / 帳面數量": resolveStockTakeBookQty(d, e.beforeQty) ?? "",
+ "Accepted Qty / 核准數量":
+ resolveStockTakeAcceptedQty(d, e.afterQty) ?? "",
+ "Variance Qty / 差異數量":
+ d?.varianceQty != null ? d.varianceQty : e.varianceQty,
+ "Approver / 核准人": orDash(d?.approverName || e.approver),
+ "Stock Taker / 初盤人": orDash(d?.stockTakerName),
+ "First Count Qty / 初盤數量": d?.pickerFirstQty ?? "",
+ "Second Count Qty / 複盤數量": d?.pickerSecondQty ?? "",
+ "Approver Count Qty / 覆盤數量": d?.approverQty ?? "",
+ "Record Status / 紀錄狀態": orDash(d?.recordStatus),
+ "Date/Time / 時間": orDash(e.timestamp),
+ };
+ });
+ return ensureRows(rows, stockTakeEmpty());
+};
+
+const productionEmpty = (): Record => ({
+ "Section / 區塊": "",
+ "Job Order / 工單": "",
+ "Doc No. / 單號": "",
+ "Item Code / 貨品編號": "",
+ "Lot / 批號": "",
+ "Item Name / 品名": "",
+ "Step / 步驟": "",
+ "Assigned Step / 對應製程": "",
+ "Qty / 數量": "",
+ "Scrap Qty / 損耗數量": "",
+ "Defect Qty / 不良數量": "",
+ "UOM / 單位": "",
+ "Operator / 操作員": "",
+ "Equipment / 設備": "",
+ "Status / 狀態": "",
+ "Date/Time / 時間": "",
+});
+
+const buildProductionSheet = (
+ data: ItemLotTraceResponse,
+ compiledGraph: CompiledTraceGraph,
+ tr: TraceLabelTranslator,
+): Record[] => {
+ const rows: Record[] = [];
+ const presentation = buildTracePresentationRows(compiledGraph.nodes, data);
+
+ const upstreamSource = data.joPrelude?.materialInputs?.length
+ ? data.joPrelude.materialInputs.map((m) => ({
+ jobOrderCode: m.jobOrderCode,
+ materialItemCode: m.materialItemCode,
+ materialLotNo: m.materialLotNo,
+ materialQty: m.materialQty,
+ materialUom: m.materialUom,
+ assignedStepName: m.assignedStepName,
+ pickOrderCode: m.pickOrderCode,
+ pickedAt: m.pickedAt,
+ materialItemName: m.materialItemName,
+ }))
+ : data.bomTrace.upstream.map((u) => ({
+ jobOrderCode: u.jobOrderCode,
+ materialItemCode: u.materialItemCode,
+ materialLotNo: u.materialLotNo,
+ materialQty: u.materialQty,
+ materialUom: "",
+ assignedStepName: "",
+ pickOrderCode: "",
+ pickedAt: null as string | null,
+ materialItemName: "",
+ }));
+
+ upstreamSource.forEach((m) => {
+ rows.push({
+ "Section / 區塊": "BOM Upstream / BOM 上游",
+ "Job Order / 工單": orDash(m.jobOrderCode),
+ "Doc No. / 單號": orDash(m.pickOrderCode),
+ "Item Code / 貨品編號": orDash(m.materialItemCode),
+ "Lot / 批號": orDash(m.materialLotNo),
+ "Item Name / 品名": orDash(m.materialItemName),
+ "Step / 步驟": "",
+ "Assigned Step / 對應製程": orDash(m.assignedStepName),
+ "Qty / 數量": m.materialQty,
+ "Scrap Qty / 損耗數量": "",
+ "Defect Qty / 不良數量": "",
+ "UOM / 單位": orDash(m.materialUom),
+ "Operator / 操作員": "",
+ "Equipment / 設備": "",
+ "Status / 狀態": "",
+ "Date/Time / 時間": orDash(m.pickedAt),
+ });
+ });
+
+ data.bomTrace.downstream.forEach((d) => {
+ rows.push({
+ "Section / 區塊": "BOM Downstream / BOM 下游",
+ "Job Order / 工單": orDash(d.jobOrderCode),
+ "Doc No. / 單號": "",
+ "Item Code / 貨品編號": orDash(d.finishedItemCode),
+ "Lot / 批號": orDash(d.finishedLotNo),
+ "Item Name / 品名": "",
+ "Step / 步驟": "",
+ "Assigned Step / 對應製程": "",
+ "Qty / 數量": d.fgQty,
+ "Scrap Qty / 損耗數量": "",
+ "Defect Qty / 不良數量": "",
+ "UOM / 單位": orDash(d.fgUom),
+ "Operator / 操作員": "",
+ "Equipment / 設備": "",
+ "Status / 狀態": "",
+ "Date/Time / 時間": "",
+ });
+ });
+
+ data.bomTrace.bomRecipe.forEach((r) => {
+ rows.push({
+ "Section / 區塊": "BOM Recipe / BOM 配方",
+ "Job Order / 工單": "",
+ "Doc No. / 單號": "",
+ "Item Code / 貨品編號": orDash(r.materialItemCode),
+ "Lot / 批號": "",
+ "Item Name / 品名": orDash(r.materialItemName),
+ "Step / 步驟": "",
+ "Assigned Step / 對應製程": orDash(r.assignedStepName),
+ "Qty / 數量": r.qtyPerUnit,
+ "Scrap Qty / 損耗數量": "",
+ "Defect Qty / 不良數量": "",
+ "UOM / 單位": orDash(r.uom),
+ "Operator / 操作員": "",
+ "Equipment / 設備": "",
+ "Status / 狀態": "",
+ "Date/Time / 時間": "",
+ });
+ });
+
+ presentation.joPicks
+ .slice()
+ .sort((a, b) => (b.pickedAt ?? "").localeCompare(a.pickedAt ?? ""))
+ .forEach((p) => {
+ rows.push({
+ "Section / 區塊": "JO Pick / 工單提料",
+ "Job Order / 工單": "",
+ "Doc No. / 單號": orDash(p.pickOrderCode),
+ "Item Code / 貨品編號": orDash(p.materialItemCode),
+ "Lot / 批號": orDash(p.materialLotNo),
+ "Item Name / 品名": "",
+ "Step / 步驟": "",
+ "Assigned Step / 對應製程": orDash(p.assignedStepName),
+ "Qty / 數量": p.qty,
+ "Scrap Qty / 損耗數量": "",
+ "Defect Qty / 不良數量": "",
+ "UOM / 單位": orDash(p.uom),
+ "Operator / 操作員": "",
+ "Equipment / 設備": "",
+ "Status / 狀態": "",
+ "Date/Time / 時間": orDash(p.pickedAt),
+ });
+ });
+
+ const productionRows =
+ data.productionSteps.length > 0
+ ? data.productionSteps
+ .slice()
+ .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0))
+ .map((s) => ({
+ "Section / 區塊": "Production Step / 生產步驟",
+ "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode),
+ "Doc No. / 單號": "",
+ "Item Code / 貨品編號": orDash(data.lot.itemCode),
+ "Lot / 批號": orDash(data.lot.lotNo),
+ "Item Name / 品名": orDash(data.lot.itemName),
+ "Step / 步驟": orDash(s.stepName),
+ "Assigned Step / 對應製程": orDash(s.description),
+ "Qty / 數量": s.outputQty,
+ "Scrap Qty / 損耗數量": s.scrapQty,
+ "Defect Qty / 不良數量": s.defectQty,
+ "UOM / 單位": orDash(data.lot.uom),
+ "Operator / 操作員": orDash(s.operatorName),
+ "Equipment / 設備":
+ [s.equipmentCode, s.equipmentName].filter(Boolean).join(" · ") ||
+ "—",
+ "Status / 狀態": tr.productionStatus(s.status),
+ "Date/Time / 時間": orDash(s.endTime || s.startTime),
+ }))
+ : presentation.production
+ .slice()
+ .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0))
+ .map((s) => ({
+ "Section / 區塊": "Production Step / 生產步驟",
+ "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode),
+ "Doc No. / 單號": "",
+ "Item Code / 貨品編號": orDash(data.lot.itemCode),
+ "Lot / 批號": orDash(s.traceLotNo || data.lot.lotNo),
+ "Item Name / 品名": orDash(data.lot.itemName),
+ "Step / 步驟": orDash(s.stepName),
+ "Assigned Step / 對應製程": orDash(s.description),
+ "Qty / 數量": s.outputQty,
+ "Scrap Qty / 損耗數量": s.scrapQty,
+ "Defect Qty / 不良數量": s.defectQty,
+ "UOM / 單位": orDash(data.lot.uom),
+ "Operator / 操作員": orDash(s.operatorName),
+ "Equipment / 設備": orDash(s.equipmentName || s.equipmentCode),
+ "Status / 狀態": orDash(s.status),
+ "Date/Time / 時間": orDash(s.endTime || s.startTime),
+ }));
+
+ rows.push(...productionRows);
+
+ data.byproductLots.forEach((b) => {
+ rows.push({
+ "Section / 區塊": "Byproduct / 副產品",
+ "Job Order / 工單": orDash(b.jobOrderCode),
+ "Doc No. / 單號": "",
+ "Item Code / 貨品編號": orDash(b.itemCode),
+ "Lot / 批號": orDash(b.lotNo),
+ "Item Name / 品名": orDash(b.itemName),
+ "Step / 步驟": orDash(b.processStepName),
+ "Assigned Step / 對應製程": "",
+ "Qty / 數量": b.qty,
+ "Scrap Qty / 損耗數量": "",
+ "Defect Qty / 不良數量": "",
+ "UOM / 單位": orDash(b.uom),
+ "Operator / 操作員": "",
+ "Equipment / 設備": "",
+ "Status / 狀態": "",
+ "Date/Time / 時間": orDash(b.producedAt),
+ });
+ });
+
+ data.lotRelations.forEach((r) => {
+ rows.push({
+ "Section / 區塊": "Lot Relation / 拆批關聯",
+ "Job Order / 工單": "",
+ "Doc No. / 單號": orDash(r.relationType),
+ "Item Code / 貨品編號": orDash(r.itemCode),
+ "Lot / 批號": orDash(r.lotNo),
+ "Item Name / 品名": orDash(r.itemName),
+ "Step / 步驟": "",
+ "Assigned Step / 對應製程": "",
+ "Qty / 數量": r.qty,
+ "Scrap Qty / 損耗數量": "",
+ "Defect Qty / 不良數量": "",
+ "UOM / 單位": "",
+ "Operator / 操作員": "",
+ "Equipment / 設備": "",
+ "Status / 狀態": orDash(r.productLotNo),
+ "Date/Time / 時間": orDash(r.timestamp),
+ });
+ });
+
+ return ensureRows(rows, productionEmpty());
+};
+
+/** Build the 8 classified sheets without triggering download (for tests). */
+export const buildItemLotTraceSheets = (
+ data: ItemLotTraceResponse,
+ compiledGraph: CompiledTraceGraph,
+ t: TFunction,
+ exportLabels: ItemLotTraceExportLabels,
+ exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19),
+): MultiSheetSpec[] => {
+ const tr = createTraceLabelTranslator(t);
+ return [
+ {
+ name: SHEET.summary,
+ rows: buildSummarySheet(data, exportLabels, exportAt),
+ },
+ { name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) },
+ { name: SHEET.origins, rows: buildOriginsSheet(data, tr) },
+ { name: SHEET.qc, rows: buildQcSheet(data, tr) },
+ { name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) },
+ { name: SHEET.outbound, rows: buildOutboundSheet(data, tr) },
+ { name: SHEET.stockTake, rows: buildStockTakeSheet(data) },
+ {
+ name: SHEET.production,
+ rows: buildProductionSheet(data, compiledGraph, tr),
+ },
+ ];
+};
+
+export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET);
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */
+export const exportItemLotTraceXlsx = (
+ data: ItemLotTraceResponse,
+ compiledGraph: CompiledTraceGraph,
+ t: TFunction,
+): void => {
+ const exportLabels: ItemLotTraceExportLabels = {
+ bomDirectionFg: t("bomDirectionFG"),
+ bomDirectionMaterial: t("bomDirectionMaterial"),
+ bomDirectionUnknown: t("bomDirectionUnknown"),
+ };
+ const sheets = buildItemLotTraceSheets(data, compiledGraph, t, exportLabels);
+ exportMultiSheetToXlsx(sheets, buildItemLotTraceFilename(data));
+};
diff --git a/src/components/ItemTracing/index.ts b/src/components/ItemTracing/index.ts
new file mode 100644
index 0000000..f1bda39
--- /dev/null
+++ b/src/components/ItemTracing/index.ts
@@ -0,0 +1,5 @@
+export { default } from "./ItemTracing";
+export { default as ItemTracingScanBar } from "./ItemTracingScanBar";
+export { default as ItemTracingSummary } from "./ItemTracingSummary";
+export { default as ItemTracingFlowGraph } from "./ItemTracingFlowGraph";
+export { default as ItemTracingSections } from "./ItemTracingSections";
diff --git a/src/components/ItemTracing/itemTracingTableFilters.tsx b/src/components/ItemTracing/itemTracingTableFilters.tsx
new file mode 100644
index 0000000..599173f
--- /dev/null
+++ b/src/components/ItemTracing/itemTracingTableFilters.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import { type ReactNode } from "react";
+
+export type FilterableColumnDef = {
+ key: string;
+ label: string;
+ align?: "left" | "right" | "center";
+ value: (row: T) => string | number | null | undefined;
+ cell?: (row: T) => ReactNode;
+ /** @deprecated Unused — filters removed. */
+ filterable?: boolean;
+};
+
+type FilterableDataTableProps = {
+ rows: T[];
+ columns: FilterableColumnDef[];
+ getRowKey: (row: T, index: number) => string;
+ emptyLabel: string;
+ size?: "small" | "medium";
+ collapseAfter?: number;
+ showAll?: boolean;
+ onToggleShowAll?: () => void;
+ showAllLabel?: string | ((count: number) => string);
+ collapseLabel?: string | ((count: number) => string);
+};
+
+const resolveCountLabel = (
+ label: string | ((count: number) => string) | undefined,
+ count: number,
+ fallback: string,
+) => (typeof label === "function" ? label(count) : (label ?? fallback));
+
+/** Plain data table (column filters removed). */
+export function FilterableDataTable({
+ rows,
+ columns,
+ getRowKey,
+ emptyLabel,
+ size = "small",
+ collapseAfter,
+ showAll = false,
+ onToggleShowAll,
+ showAllLabel,
+ collapseLabel,
+}: FilterableDataTableProps) {
+ const collapsed =
+ collapseAfter != null && rows.length > collapseAfter && !showAll;
+ const visibleRows = collapsed ? rows.slice(0, collapseAfter) : rows;
+
+ return (
+ <>
+
+
+
+ {columns.map((col) => (
+
+ {col.label}
+
+ ))}
+
+
+
+ {visibleRows.length === 0 ? (
+
+
+
+ {emptyLabel}
+
+
+
+ ) : (
+ visibleRows.map((row, index) => (
+
+ {columns.map((col) => (
+
+ {col.cell ? col.cell(row) : (col.value(row) ?? "—")}
+
+ ))}
+
+ ))
+ )}
+
+
+ {collapseAfter != null &&
+ rows.length > collapseAfter &&
+ onToggleShowAll && (
+
+ {showAll
+ ? resolveCountLabel(collapseLabel, rows.length, `▲ ${rows.length}`)
+ : resolveCountLabel(showAllLabel, rows.length, `▼ ${rows.length}`)}
+
+ )}
+ >
+ );
+}
diff --git a/src/components/ItemTracing/mergeLocationScopedData.ts b/src/components/ItemTracing/mergeLocationScopedData.ts
new file mode 100644
index 0000000..1bab5a1
--- /dev/null
+++ b/src/components/ItemTracing/mergeLocationScopedData.ts
@@ -0,0 +1,316 @@
+import type {
+ ItemLotTraceLocationBlock,
+ ItemLotTraceResponse,
+} from "@/app/api/itemTracing";
+import { blockWarehouseCode } from "./buildLocationBlockGraphNodes";
+
+export type ScopedRow = T & {
+ scopeWarehouseCode: string;
+ inventoryLotId: number;
+};
+
+export const primaryWarehouseLabel = (data: ItemLotTraceResponse): string =>
+ data.warehouseLines
+ .map((w) => w.warehouseCode)
+ .filter(Boolean)
+ .join(" / ") || "—";
+
+const sortByTimestampDesc = (
+ rows: T[],
+): T[] =>
+ [...rows].sort((a, b) =>
+ (b.timestamp ?? "").localeCompare(a.timestamp ?? ""),
+ );
+
+const sortByReceiptDateDesc = (
+ rows: T[],
+): T[] =>
+ [...rows].sort((a, b) =>
+ (b.receiptDate ?? "").localeCompare(a.receiptDate ?? ""),
+ );
+
+export const mergeScopedOrigins = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.origins.map((o) => ({
+ ...o,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.origins.map((o) => ({
+ ...o,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ }));
+ });
+ return sortByReceiptDateDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedQcResults = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.qcResults.map((q, i) => ({
+ ...q,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-${q.stockInLineId}-${i}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.qcResults.map((q, i) => ({
+ ...q,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-${q.stockInLineId}-${i}`,
+ }));
+ });
+ return [...primary, ...fromBlocks].sort((a, b) =>
+ (b.created ?? "").localeCompare(a.created ?? ""),
+ );
+};
+
+export const mergeScopedOutboundUsage = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.outboundUsage.map((u) => ({
+ ...u,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.outboundUsage.map((u) => ({
+ ...u,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedStockTakeEvents = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.stockTakeEvents.map((e, i) => ({
+ ...e,
+ scopeWarehouseCode: e.warehouseCode?.trim() || primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-${e.stockTakeCode}-${i}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.stockTakeEvents.map((e, i) => ({
+ ...e,
+ scopeWarehouseCode: e.warehouseCode?.trim() || wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-${e.stockTakeCode}-${i}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedAdjustments = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.adjustments.map((a, i) => ({
+ ...a,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-${a.refCode}-${i}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.adjustments.map((a, i) => ({
+ ...a,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-${a.refCode}-${i}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedTransfers = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.transfers.map((tr, i) => ({
+ ...tr,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-${tr.transferCode}-${i}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.transfers.map((tr, i) => ({
+ ...tr,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-${tr.transferCode}-${i}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedMovements = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.movements.map((m, i) => ({
+ ...m,
+ scopeWarehouseCode: m.warehouseCode?.trim() || primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-mov-${i}-${m.refCode}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.movements.map((m, i) => ({
+ ...m,
+ scopeWarehouseCode: m.warehouseCode?.trim() || wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-mov-${i}-${m.refCode}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedPutawayEvents = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.putawayEvents.map((p, i) => ({
+ ...p,
+ scopeWarehouseCode: p.warehouseCode?.trim() || primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-putaway-${i}-${p.refCode}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.putawayEvents.map((p, i) => ({
+ ...p,
+ scopeWarehouseCode: p.warehouseCode?.trim() || wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-putaway-${i}-${p.refCode}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedPurchaseEvents = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = (data.purchaseEvents ?? []).map((p, i) => ({
+ ...p,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-po-${i}-${p.purchaseOrderLineId}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return (block.purchaseEvents ?? []).map((p, i) => ({
+ ...p,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-po-${i}-${p.purchaseOrderLineId}`,
+ }));
+ });
+ return [...primary, ...fromBlocks].sort((a, b) =>
+ (b.orderDate ?? "").localeCompare(a.orderDate ?? ""),
+ );
+};
+
+export const mergeScopedDoDeliveries = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.doDeliveries.map((d, i) => ({
+ ...d,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-do-${i}-${d.stockOutLineId}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.doDeliveries.map((d, i) => ({
+ ...d,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-do-${i}-${d.stockOutLineId}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedFailEvents = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.failEvents.map((f, i) => ({
+ ...f,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-fail-${i}-${f.failId}`,
+ timestamp: f.recordDate,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.failEvents.map((f, i) => ({
+ ...f,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-fail-${i}-${f.failId}`,
+ timestamp: f.recordDate,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedReturnEvents = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.returnEvents.map((r, i) => ({
+ ...r,
+ scopeWarehouseCode: r.warehouseCode?.trim() || primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-return-${i}-${r.stockOutLineId}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.returnEvents.map((r, i) => ({
+ ...r,
+ scopeWarehouseCode: r.warehouseCode?.trim() || wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-return-${i}-${r.stockOutLineId}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedOpenMovements = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = data.openMovements.map((o, i) => ({
+ ...o,
+ scopeWarehouseCode: o.warehouseCode?.trim() || primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-open-${i}-${o.stockInLineId}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return block.openMovements.map((o, i) => ({
+ ...o,
+ scopeWarehouseCode: o.warehouseCode?.trim() || wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-open-${i}-${o.stockInLineId}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const mergeScopedReplenishmentEvents = (data: ItemLotTraceResponse) => {
+ const primaryWh = primaryWarehouseLabel(data);
+ const primary = (data.replenishmentEvents ?? []).map((r, i) => ({
+ ...r,
+ scopeWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ rowKey: `primary-replenish-${i}-${r.replenishmentId}`,
+ }));
+ const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
+ const wh = blockWarehouseCode(block);
+ return (block.replenishmentEvents ?? []).map((r, i) => ({
+ ...r,
+ scopeWarehouseCode: wh,
+ inventoryLotId: block.inventoryLotId,
+ rowKey: `loc-${block.inventoryLotId}-replenish-${i}-${r.replenishmentId}`,
+ }));
+ });
+ return sortByTimestampDesc([...primary, ...fromBlocks]);
+};
+
+export const hasMultipleLocations = (data: ItemLotTraceResponse): boolean =>
+ (data.locationBlocks?.length ?? 0) > 0 ||
+ (data.alternateLocations?.length ?? 0) > 0;
diff --git a/src/components/ItemTracing/traceDoGroupLayout.ts b/src/components/ItemTracing/traceDoGroupLayout.ts
new file mode 100644
index 0000000..3a6066b
--- /dev/null
+++ b/src/components/ItemTracing/traceDoGroupLayout.ts
@@ -0,0 +1,274 @@
+import {
+ DO_GROUP_CHILD_WIDTH,
+ DO_GROUP_HEADER,
+ DO_GROUP_INNER_COLS_MAX,
+ DO_GROUP_MIN_SIZE,
+ DO_GROUP_PAD,
+ PICK_GROUP_MIN_SIZE,
+ NODE_GAP,
+ NODE_HEIGHT,
+ NODE_WIDTH,
+} from "./traceFlowConstants";
+import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout";
+import { TraceGraphNode } from "./buildTraceGraphNodes";
+
+export type DoGroupChildPlacement = { x: number; y: number; compact: boolean };
+
+/** Fewer columns for large groups — wider cards, no horizontal cramming. */
+const preferredDoGroupCols = (memberCount: number): number => {
+ if (memberCount <= 1) return 1;
+ if (memberCount >= 15) return 2;
+ if (memberCount >= 6) return 3;
+ return 2;
+};
+
+const sumDoGroupQty = (
+ children: TraceGraphLayoutNode[],
+): { totalQty: number; uom?: string } | null => {
+ let totalQty = 0;
+ let hasQty = false;
+ let uom: string | undefined;
+ children.forEach((child) => {
+ const qty = child.qty;
+ if (qty != null && Number.isFinite(Number(qty))) {
+ totalQty += Number(qty);
+ hasQty = true;
+ }
+ if (!uom && child.uom?.trim()) uom = child.uom.trim();
+ });
+ return hasQty ? { totalQty, uom } : null;
+};
+
+export const computeDoGroupBoxLayout = (
+ memberCount: number,
+): { width: number; height: number; cols: number } => {
+ const childW = DO_GROUP_CHILD_WIDTH;
+ const cols = Math.min(DO_GROUP_INNER_COLS_MAX, memberCount, preferredDoGroupCols(memberCount));
+ const rows = Math.ceil(memberCount / cols);
+ const gridW = cols * childW + Math.max(0, cols - 1) * NODE_GAP;
+ const gridH = rows * NODE_HEIGHT + Math.max(0, rows - 1) * NODE_GAP;
+ return {
+ width: gridW + DO_GROUP_PAD * 2,
+ height: DO_GROUP_HEADER + gridH + DO_GROUP_PAD * 2,
+ cols,
+ };
+};
+
+export const layoutDoGroupChildPlacements = (
+ children: TraceGraphLayoutNode[],
+): Map => {
+ const sorted = [...children].sort(sortNodesInPhase);
+ const { cols } = computeDoGroupBoxLayout(sorted.length);
+ const childW = DO_GROUP_CHILD_WIDTH;
+ const result = new Map();
+
+ sorted.forEach((child, index) => {
+ const col = index % cols;
+ const row = Math.floor(index / cols);
+ result.set(child.id, {
+ x: DO_GROUP_PAD + col * (childW + NODE_GAP),
+ y: DO_GROUP_HEADER + DO_GROUP_PAD + row * (NODE_HEIGHT + NODE_GAP),
+ compact: false,
+ });
+ });
+
+ return result;
+};
+
+export const applyDoOutboundGrouping = (
+ nodes: TraceGraphLayoutNode[],
+ groupTitle: (count: number) => string,
+ minSize = DO_GROUP_MIN_SIZE,
+): TraceGraphLayoutNode[] => {
+ const doByColumnAndWarehouse = new Map();
+
+ nodes.forEach((node) => {
+ if (node.kind !== "DO_OUT" || node.doGroupId) return;
+ const whKey = (node.warehouseCode ?? "").trim().toUpperCase() || "__none__";
+ const key = `${node.column}::${whKey}`;
+ const list = doByColumnAndWarehouse.get(key) ?? [];
+ list.push(node);
+ doByColumnAndWarehouse.set(key, list);
+ });
+
+ const groupNodes: TraceGraphLayoutNode[] = [];
+ const childGroupIds = new Map();
+
+ doByColumnAndWarehouse.forEach((columnNodes, key) => {
+ if (columnNodes.length < minSize) return;
+
+ const column = columnNodes[0]!.column;
+ const warehouseCode = columnNodes[0]!.warehouseCode?.trim() || undefined;
+ const whSlug = (warehouseCode ?? "none").replace(/[^a-zA-Z0-9_-]/g, "_");
+ const groupId = `do-group-col-${column}-${whSlug}`;
+ const sorted = [...columnNodes].sort(sortNodesInPhase);
+ const { width, height } = computeDoGroupBoxLayout(sorted.length);
+ const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey);
+ const qtySummary = sumDoGroupQty(sorted);
+
+ sorted.forEach((child) => childGroupIds.set(child.id, groupId));
+
+ const groupNode: TraceGraphLayoutNode = {
+ id: groupId,
+ kind: "DO_GROUP",
+ timestamp: sorted[0]?.timestamp ?? null,
+ sortKey: minSort,
+ title: groupTitle(sorted.length),
+ subtitle: [sorted[0]?.dayKey, warehouseCode].filter(Boolean).join(" · ") || "",
+ categoryLabel: sorted[0]?.categoryLabel,
+ warehouseCode,
+ details: [],
+ phase: sorted[0]!.phase,
+ column,
+ dayKey: sorted[0]!.dayKey,
+ laneIndex: sorted[0]!.laneIndex,
+ sequenceIndex: sorted[0]!.sequenceIndex,
+ branchIndex: 0,
+ branchSize: 1,
+ dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex,
+ groupBoxWidth: width,
+ groupBoxHeight: height,
+ groupMemberCount: sorted.length,
+ groupTotalQty: qtySummary?.totalQty,
+ groupUom: qtySummary?.uom,
+ };
+ groupNodes.push(groupNode);
+ });
+
+ if (!groupNodes.length) return nodes;
+
+ return [
+ ...nodes.map((node) => {
+ const groupId = childGroupIds.get(node.id);
+ if (!groupId) return node;
+ return { ...node, doGroupId: groupId };
+ }),
+ ...groupNodes,
+ ].sort((a, b) => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ return a.id.localeCompare(b.id);
+ });
+};
+
+export const applyMaterialPickGrouping = (
+ nodes: TraceGraphLayoutNode[],
+ groupTitle: (pickOrderCode: string, count: number) => string,
+ minSize = PICK_GROUP_MIN_SIZE,
+): TraceGraphLayoutNode[] => {
+ const picksByKey = new Map();
+
+ nodes.forEach((node) => {
+ if (node.kind !== "MATERIAL_PICK" || node.doGroupId) return;
+ const pickCode = node.refCode?.trim();
+ if (!pickCode) return;
+ const key = `${node.column}::${pickCode}`;
+ const list = picksByKey.get(key) ?? [];
+ list.push(node);
+ picksByKey.set(key, list);
+ });
+
+ const groupNodes: TraceGraphLayoutNode[] = [];
+ const childGroupIds = new Map();
+
+ picksByKey.forEach((columnNodes, key) => {
+ if (columnNodes.length < minSize) return;
+
+ const pickCode = key.split("::").slice(1).join("::");
+ const groupId = `pick-group-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
+ const sorted = [...columnNodes].sort(sortNodesInPhase);
+ const { width, height } = computeDoGroupBoxLayout(sorted.length);
+ const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey);
+
+ sorted.forEach((child) => childGroupIds.set(child.id, groupId));
+
+ const groupNode: TraceGraphLayoutNode = {
+ id: groupId,
+ kind: "PICK_GROUP",
+ timestamp: sorted[0]?.timestamp ?? null,
+ sortKey: minSort,
+ title: groupTitle(pickCode, sorted.length),
+ subtitle: sorted[0]?.dayKey ?? "",
+ refCode: pickCode,
+ refId: sorted[0]?.refId,
+ docLinkKind: "pick",
+ consoCode: sorted[0]?.consoCode,
+ jobOrderCode: sorted[0]?.jobOrderCode,
+ categoryLabel: sorted[0]?.categoryLabel,
+ details: [],
+ phase: sorted[0]!.phase,
+ column: sorted[0]!.column,
+ dayKey: sorted[0]!.dayKey,
+ laneIndex: sorted[0]!.laneIndex,
+ sequenceIndex: sorted[0]!.sequenceIndex,
+ branchIndex: 0,
+ branchSize: 1,
+ dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex,
+ groupBoxWidth: width,
+ groupBoxHeight: height,
+ groupMemberCount: sorted.length,
+ };
+ groupNodes.push(groupNode);
+ });
+
+ if (!groupNodes.length) return nodes;
+
+ return [
+ ...nodes.map((node) => {
+ const groupId = childGroupIds.get(node.id);
+ if (!groupId) return node;
+ return { ...node, doGroupId: groupId };
+ }),
+ ...groupNodes,
+ ].sort((a, b) => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ return a.id.localeCompare(b.id);
+ });
+};
+
+export const applyFlowNodeGrouping = (
+ nodes: TraceGraphLayoutNode[],
+ labels: {
+ flowDoGroupTitle: (count: number) => string;
+ flowPickGroupTitle: (pickOrderCode: string, count: number) => string;
+ },
+): TraceGraphLayoutNode[] =>
+ applyMaterialPickGrouping(
+ applyDoOutboundGrouping(nodes, labels.flowDoGroupTitle),
+ labels.flowPickGroupTitle,
+ );
+
+export const regroupLayoutCells = (
+ nodes: TraceGraphLayoutNode[],
+): TraceGraphLayoutNode[][] => {
+ const visible = nodes.filter((n) => !isDoGroupChild(n));
+ const groups = new Map();
+
+ visible.forEach((node) => {
+ const key = `${node.column}:${node.laneIndex}`;
+ const list = groups.get(key) ?? [];
+ list.push(node);
+ groups.set(key, list);
+ });
+
+ groups.forEach((list) => {
+ list.sort(sortNodesInPhase);
+ const size = list.length;
+ list.forEach((node, idx) => {
+ node.branchIndex = idx;
+ node.branchSize = size;
+ });
+ });
+
+ return Array.from(groups.values()).sort((a, b) => {
+ const na = a[0]!;
+ const nb = b[0]!;
+ if (na.column !== nb.column) return na.column - nb.column;
+ return na.laneIndex - nb.laneIndex;
+ });
+};
+
+export const isFlowGroupContainer = (node: TraceGraphNode | TraceGraphLayoutNode): boolean =>
+ node.kind === "DO_GROUP" || node.kind === "PICK_GROUP";
+
+export const isDoGroupChild = (node: TraceGraphNode | TraceGraphLayoutNode): boolean =>
+ Boolean(node.doGroupId?.trim());
diff --git a/src/components/ItemTracing/traceDocLinkUtils.ts b/src/components/ItemTracing/traceDocLinkUtils.ts
new file mode 100644
index 0000000..56a4882
--- /dev/null
+++ b/src/components/ItemTracing/traceDocLinkUtils.ts
@@ -0,0 +1,155 @@
+import dayjs from "dayjs";
+import {
+ ItemLotTraceJoPrelude,
+ ItemLotTraceResponse,
+} from "@/app/api/itemTracing";
+
+export const isWorkbenchTicketNo = (ticketNo?: string | null): boolean =>
+ (ticketNo ?? "").trim().toUpperCase().startsWith("TI-");
+
+/** Normalize any date/datetime string to YYYY-MM-DD for API / URL query params (LocalDate). */
+export const normalizeTargetDateForLink = (
+ value?: string | null,
+): string | undefined => {
+ if (value == null) return undefined;
+ const raw = String(value).trim();
+ if (!raw) return undefined;
+ // Prefer extracting YYYY-MM-DD prefix — dayjs/Date.parse is unreliable for
+ // "YYYY-MM-DD HH:mm:ss" in some browsers (e.g. Safari).
+ const match = raw.match(/(\d{4}-\d{2}-\d{2})/);
+ if (match) return match[1];
+ const d = dayjs(raw);
+ return d.isValid() ? d.format("YYYY-MM-DD") : undefined;
+};
+
+export const targetDateFromTraceTimestamp = (
+ timestamp?: string | null,
+): string | undefined => normalizeTargetDateForLink(timestamp);
+
+export type DoOutboundDocLink = {
+ kind: "workbench" | "pick" | undefined;
+ ticketNo?: string;
+ targetDate?: string;
+ /** Visible link label (DO code preferred on DO_OUT cards). */
+ displayCode: string;
+ pickOrderId?: number | null;
+ consoCode?: string;
+};
+
+export type JoPickDocLink = {
+ kind: "jodetail";
+ pickOrderCode: string;
+ targetDate?: string;
+ displayCode: string;
+ pickOrderId?: number | null;
+};
+
+const walkJoPreludePickOrders = (
+ prelude: ItemLotTraceJoPrelude,
+ map: Map,
+): void => {
+ prelude.pickOrders.forEach((po) => {
+ const code = po.pickOrderCode?.trim();
+ const date = normalizeTargetDateForLink(po.targetDate);
+ if (code && date) map.set(code, date);
+ });
+ prelude.materialInputs.forEach((m) => {
+ if (m.nestedJoPrelude) walkJoPreludePickOrders(m.nestedJoPrelude, map);
+ });
+};
+
+export const buildPickOrderTargetDateMapFromPrelude = (
+ prelude: ItemLotTraceJoPrelude,
+): Map => {
+ const map = new Map();
+ walkJoPreludePickOrders(prelude, map);
+ return map;
+};
+
+export const buildPickOrderTargetDateMap = (
+ data: ItemLotTraceResponse,
+): Map =>
+ data.joPrelude ? buildPickOrderTargetDateMapFromPrelude(data.joPrelude) : new Map();
+
+export const resolvePickOrderTargetDate = (
+ map: Map,
+ pickOrderCode?: string | null,
+ fallbackTimestamp?: string | null,
+): string | undefined => {
+ const code = pickOrderCode?.trim();
+ if (code) {
+ const fromPrelude = normalizeTargetDateForLink(map.get(code));
+ if (fromPrelude) return fromPrelude;
+ }
+ return targetDateFromTraceTimestamp(fallbackTimestamp);
+};
+
+export const resolveJoPickDocLink = (input: {
+ pickOrderCode?: string;
+ pickOrderId?: number | null;
+ timestamp?: string | null;
+ targetDate?: string | null;
+}): JoPickDocLink | null => {
+ const pickCode = input.pickOrderCode?.trim() || "";
+ if (!pickCode) return null;
+ return {
+ kind: "jodetail",
+ pickOrderCode: pickCode,
+ targetDate:
+ normalizeTargetDateForLink(input.targetDate) ||
+ targetDateFromTraceTimestamp(input.timestamp),
+ displayCode: pickCode,
+ pickOrderId: input.pickOrderId,
+ };
+};
+
+export const resolveDoOutboundDocLink = (input: {
+ pickOrderCode?: string;
+ pickOrderId?: number | null;
+ deliveryOrderCode?: string;
+ ticketNo?: string;
+ outboundTicketNo?: string;
+ consoCode?: string;
+ timestamp?: string | null;
+ /** Workbench path only when linked to delivery_order_pick_order. */
+ deliveryOrderPickOrderId?: number | null;
+}): DoOutboundDocLink => {
+ const ticket = (input.ticketNo || input.outboundTicketNo || "").trim();
+ const conso = (input.consoCode || "").trim();
+ const hasWorkbenchLink =
+ input.deliveryOrderPickOrderId != null &&
+ Number.isFinite(Number(input.deliveryOrderPickOrderId));
+ const workbenchTicket = hasWorkbenchLink
+ ? isWorkbenchTicketNo(ticket)
+ ? ticket
+ : isWorkbenchTicketNo(conso)
+ ? conso
+ : undefined
+ : undefined;
+ const targetDate = targetDateFromTraceTimestamp(input.timestamp);
+ const pickCode = input.pickOrderCode?.trim() || "";
+ const doCode = input.deliveryOrderCode?.trim() || "";
+
+ if (workbenchTicket) {
+ return {
+ kind: "workbench",
+ ticketNo: workbenchTicket,
+ targetDate,
+ displayCode: doCode || pickCode || workbenchTicket,
+ pickOrderId: input.pickOrderId,
+ consoCode: conso || pickCode,
+ };
+ }
+ // Prefer 送貨單號 on the card; /pickOrder/detail is retired so no pick-only link.
+ // Legacy GoodPick TI-* without deliveryOrderPickOrderId must not deep-link to workbench.
+ if (doCode || pickCode) {
+ return {
+ kind: undefined,
+ displayCode: doCode || pickCode,
+ pickOrderId: input.pickOrderId,
+ consoCode: conso || pickCode,
+ targetDate,
+ };
+ }
+ return { kind: undefined, displayCode: "—" };
+};
diff --git a/src/components/ItemTracing/traceFlowConstants.ts b/src/components/ItemTracing/traceFlowConstants.ts
new file mode 100644
index 0000000..41ee187
--- /dev/null
+++ b/src/components/ItemTracing/traceFlowConstants.ts
@@ -0,0 +1,38 @@
+export const LANE_MIN_HEIGHT = 150;
+export const LANE_GAP = 32;
+/** Minimum width of one phase slot within a calendar day column. */
+export const COLUMN_WIDTH_MIN = 320;
+/** @deprecated use COLUMN_WIDTH_MIN — kept for imports that expect COLUMN_WIDTH */
+export const COLUMN_WIDTH = COLUMN_WIDTH_MIN;
+export const PHASE_LABEL_WIDTH = 112;
+export const HEADER_HEIGHT = 40;
+/** Trace event card width — sized for PO codes and supplier names. */
+export const NODE_WIDTH = 268;
+export const NODE_WIDTH_BRANCH = 220;
+/** Fixed trace event card height — layout spacing must match rendered Paper height.
+ * Sized for densest cards (MATERIAL_PICK / JO_OUT with item, statuses, timestamp). */
+export const NODE_HEIGHT = 280;
+export const NODE_GAP = 16;
+/** React Flow canvas height in the life-cycle graph panel. */
+export const VIEWPORT_HEIGHT = 720;
+/** Initial fitView will not zoom out below this (keeps nodes readable). */
+export const FIT_VIEW_MIN_ZOOM = 0.55;
+export const FIT_VIEW_MAX_ZOOM = 1.35;
+
+/**
+ * Min DO_OUT nodes in one day column before collapsing into a group box.
+ * Higher than pick groups: individual DO tickets stay readable until the column
+ * gets dense (≥3 same day + warehouse).
+ */
+export const DO_GROUP_MIN_SIZE = 3;
+/**
+ * Min MATERIAL_PICK lines sharing a pick order before collapsing into a group box.
+ * Intentional asymmetry vs DO_GROUP_MIN_SIZE: warehouse users think in pick-order
+ * batches, so even a single material line sits inside its pick-order box.
+ */
+export const PICK_GROUP_MIN_SIZE = 1;
+export const DO_GROUP_PAD = 12;
+export const DO_GROUP_HEADER = 32;
+export const DO_GROUP_INNER_COLS_MAX = 3;
+/** Card width used inside a DO group grid (full-size, not branch compact). */
+export const DO_GROUP_CHILD_WIDTH = NODE_WIDTH;
diff --git a/src/components/ItemTracing/traceFlowEdgeLayout.ts b/src/components/ItemTracing/traceFlowEdgeLayout.ts
new file mode 100644
index 0000000..c479123
--- /dev/null
+++ b/src/components/ItemTracing/traceFlowEdgeLayout.ts
@@ -0,0 +1,395 @@
+export type TraceFlowPair = { fromId: string; toId: string };
+
+export type RoutedTraceFlowEdge = TraceFlowPair & {
+ sourceHandle: string;
+ targetHandle: string;
+ offset: number;
+ pathMode?: "smooth" | "corridor";
+ corridorY?: number;
+ /** Added to React Flow sourceX for the first horizontal stub. */
+ corridorExitOffset?: number;
+ /** Added to React Flow targetX for the vertical drop into the target. */
+ corridorEntryOffset?: number;
+ /**
+ * Added to React Flow sourceX for the late branch point on the corridor.
+ * Shared-trunk fans travel together until this X, then fork to each target.
+ */
+ corridorBranchOffset?: number;
+};
+
+export type TraceFlowNodeRect = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ laneIndex: number;
+};
+
+export type TraceFlowLaneLayout = {
+ laneTops: number[];
+ laneHeights: number[];
+ laneGap: number;
+};
+
+const OFFSET_STEP = 18;
+const CORRIDOR_STEP = 14;
+const HORIZONTAL_STUB = 14;
+const MIN_HORIZONTAL_SPAN = 24;
+
+export const TRACE_FLOW_HANDLE_IN = "in";
+export const TRACE_FLOW_HANDLE_OUT = "out";
+
+const stableSortPairs = (list: TraceFlowPair[]): TraceFlowPair[] =>
+ [...list].sort((a, b) => {
+ const keyA = `${a.fromId}->${a.toId}`;
+ const keyB = `${b.fromId}->${b.toId}`;
+ return keyA.localeCompare(keyB);
+ });
+
+const edgeKey = (fromId: string, toId: string) => `${fromId}->${toId}`;
+
+const targetColumnKey = (rect: TraceFlowNodeRect) =>
+ `${rect.laneIndex}:${Math.round(rect.x)}`;
+
+const sourceColumnKey = (rect: TraceFlowNodeRect) =>
+ `${rect.laneIndex}:${Math.round(rect.x)}`;
+
+type CorridorMeta = {
+ corridorY: number;
+ corridorExitOffset: number;
+ corridorEntryOffset: number;
+ corridorBranchOffset?: number;
+};
+
+/** Spread multiple edges from the same node via handles + smoothstep offset. */
+export const assignTraceFlowEdgeRouting = (
+ pairs: TraceFlowPair[],
+): {
+ routed: RoutedTraceFlowEdge[];
+ incomingCount: Map;
+ outgoingCount: Map;
+} => {
+ const bySource = new Map();
+ const byTarget = new Map();
+
+ pairs.forEach((p) => {
+ const outList = bySource.get(p.fromId) ?? [];
+ outList.push(p);
+ bySource.set(p.fromId, outList);
+
+ const inList = byTarget.get(p.toId) ?? [];
+ inList.push(p);
+ byTarget.set(p.toId, inList);
+ });
+
+ bySource.forEach((list, id) => bySource.set(id, stableSortPairs(list)));
+ byTarget.forEach((list, id) => byTarget.set(id, stableSortPairs(list)));
+
+ const incomingCount = new Map();
+ const outgoingCount = new Map();
+ byTarget.forEach((list, id) => incomingCount.set(id, list.length));
+ // 1→N fans use a single source handle (shared trunk); report 1 outbound slot.
+ bySource.forEach((list, id) => outgoingCount.set(id, list.length > 1 ? 1 : list.length));
+
+ const routed: RoutedTraceFlowEdge[] = pairs.map((p) => {
+ const outList = bySource.get(p.fromId) ?? [p];
+ const inList = byTarget.get(p.toId) ?? [p];
+ const sourceIndex = outList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId);
+ const targetIndex = inList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId);
+
+ // 1→N fans leave from one handle so the stroke starts as a single trunk.
+ const sourceHandle =
+ outList.length > 1
+ ? `${TRACE_FLOW_HANDLE_OUT}-0`
+ : `${TRACE_FLOW_HANDLE_OUT}-${Math.max(0, sourceIndex)}`;
+ const targetHandle = `${TRACE_FLOW_HANDLE_IN}-${Math.max(0, targetIndex)}`;
+
+ let offset = 0;
+ if (outList.length > 1) {
+ offset = 0;
+ } else if (inList.length > 1) {
+ offset = (targetIndex - (inList.length - 1) / 2) * OFFSET_STEP;
+ }
+
+ return { ...p, sourceHandle, targetHandle, offset, pathMode: "smooth" };
+ });
+
+ return { routed, incomingCount, outgoingCount };
+};
+
+const baseCorridorY = (
+ src: TraceFlowNodeRect,
+ srcLane: number,
+ tgtLane: number,
+ lanes: TraceFlowLaneLayout,
+): number => {
+ if (srcLane !== tgtLane) {
+ const srcLaneBottom =
+ (lanes.laneTops[srcLane] ?? src.y) + (lanes.laneHeights[srcLane] ?? 0);
+ return srcLaneBottom + lanes.laneGap * 0.42;
+ }
+ return src.y + src.height + 12;
+};
+
+/**
+ * Route long horizontal spans through the lane gutter so edges do not cut across
+ * unrelated nodes in the same swimlane.
+ * 1→N source fans share one corridor trunk and only branch near targets.
+ */
+export const enrichTraceFlowCorridorRouting = (
+ routed: RoutedTraceFlowEdge[],
+ rects: Map,
+ lanes: TraceFlowLaneLayout,
+): RoutedTraceFlowEdge[] => {
+ const outDegree = new Map();
+ routed.forEach((edge) => {
+ outDegree.set(edge.fromId, (outDegree.get(edge.fromId) ?? 0) + 1);
+ });
+
+ const corridorCandidates: RoutedTraceFlowEdge[] = [];
+
+ routed.forEach((edge) => {
+ const src = rects.get(edge.fromId);
+ const tgt = rects.get(edge.toId);
+ if (!src || !tgt) return;
+
+ const srcRight = src.x + src.width;
+ const crossLane = src.laneIndex !== tgt.laneIndex;
+ const longSpan = tgt.x > srcRight + MIN_HORIZONTAL_SPAN;
+ const sourceFan = (outDegree.get(edge.fromId) ?? 0) > 1;
+ if (crossLane || longSpan || sourceFan) {
+ corridorCandidates.push(edge);
+ }
+ });
+
+ const corridorMetaByKey = new Map();
+ const groups = new Map();
+
+ corridorCandidates.forEach((edge) => {
+ const src = rects.get(edge.fromId);
+ const tgt = rects.get(edge.toId);
+ if (!src || !tgt) return;
+ const groupKey = `${src.laneIndex}->${tgt.laneIndex}`;
+ const list = groups.get(groupKey) ?? [];
+ list.push(edge);
+ groups.set(groupKey, list);
+ });
+
+ groups.forEach((edges, groupKey) => {
+ const [srcLaneStr, tgtLaneStr] = groupKey.split("->");
+ const srcLane = Number(srcLaneStr);
+ const tgtLane = Number(tgtLaneStr);
+
+ const byFrom = new Map();
+ edges.forEach((edge) => {
+ const list = byFrom.get(edge.fromId) ?? [];
+ list.push(edge);
+ byFrom.set(edge.fromId, list);
+ });
+
+ // Distinct sources in this lane-pair get separated corridor Y bands.
+ const sourceIds = Array.from(byFrom.keys()).sort((a, b) => {
+ const ax = rects.get(a)?.x ?? 0;
+ const bx = rects.get(b)?.x ?? 0;
+ if (ax !== bx) return ax - bx;
+ const ay = rects.get(a)?.y ?? 0;
+ const by = rects.get(b)?.y ?? 0;
+ return ay - by || a.localeCompare(b);
+ });
+
+ sourceIds.forEach((fromId, sourceIndex) => {
+ const fan = [...(byFrom.get(fromId) ?? [])].sort((a, b) => {
+ const aty = rects.get(a.toId)?.y ?? 0;
+ const bty = rects.get(b.toId)?.y ?? 0;
+ if (aty !== bty) return aty - bty;
+ const atx = rects.get(a.toId)?.x ?? 0;
+ const btx = rects.get(b.toId)?.x ?? 0;
+ return (
+ atx - btx ||
+ edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId))
+ );
+ });
+ const src = rects.get(fromId);
+ if (!src || !fan.length) return;
+
+ const baseY = baseCorridorY(src, srcLane, tgtLane, lanes);
+ const corridorY = baseY + sourceIndex * CORRIDOR_STEP;
+ const sharedExit = HORIZONTAL_STUB;
+ const srcRight = src.x + src.width;
+ const isFan = fan.length > 1;
+ const entrySpread = isFan ? (fan.length - 1) * CORRIDOR_STEP : 0;
+
+ const fanMetas = fan.map((edge, fanIndex) => {
+ const tgt = rects.get(edge.toId);
+ const corridorEntryOffset = isFan
+ ? -(HORIZONTAL_STUB + entrySpread) + fanIndex * CORRIDOR_STEP
+ : -HORIZONTAL_STUB;
+ const entryXLayout = (tgt?.x ?? 0) + corridorEntryOffset;
+ return { edge, corridorEntryOffset, entryXLayout };
+ });
+
+ let sharedBranchOffset: number | undefined;
+ if (isFan && fanMetas.length) {
+ const minEntryX = Math.min(...fanMetas.map((m) => m.entryXLayout));
+ sharedBranchOffset = minEntryX - srcRight;
+ }
+
+ fanMetas.forEach(({ edge, corridorEntryOffset }) => {
+ corridorMetaByKey.set(edgeKey(edge.fromId, edge.toId), {
+ corridorY,
+ corridorExitOffset: sharedExit,
+ corridorEntryOffset,
+ corridorBranchOffset: sharedBranchOffset,
+ });
+ });
+ });
+ });
+
+ // Separate vertical drops into the same target column (stacked nodes),
+ // without breaking shared-trunk exit / branch for same-source fans.
+ const byTargetColumn = new Map();
+ corridorCandidates.forEach((edge) => {
+ const tgt = rects.get(edge.toId);
+ if (!tgt) return;
+ const key = targetColumnKey(tgt);
+ const list = byTargetColumn.get(key) ?? [];
+ list.push(edge);
+ byTargetColumn.set(key, list);
+ });
+
+ byTargetColumn.forEach((edges) => {
+ if (edges.length <= 1) return;
+
+ const sorted = [...edges].sort((a, b) => {
+ const aty = rects.get(a.toId)?.y ?? 0;
+ const bty = rects.get(b.toId)?.y ?? 0;
+ return aty - bty || edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId));
+ });
+
+ const spread = (sorted.length - 1) * CORRIDOR_STEP;
+
+ sorted.forEach((edge, index) => {
+ const key = edgeKey(edge.fromId, edge.toId);
+ const meta = corridorMetaByKey.get(key);
+ if (!meta) return;
+ // Same-source fans already have entry offsets relative to the fan.
+ if ((outDegree.get(edge.fromId) ?? 0) > 1) return;
+ corridorMetaByKey.set(key, {
+ ...meta,
+ corridorEntryOffset: -(HORIZONTAL_STUB + spread) + index * CORRIDOR_STEP,
+ });
+ });
+ });
+
+ // Separate trunks when multiple distinct sources share a column (stacked nodes).
+ // Same-source fan edges keep a shared exit; different sources get staggered exits.
+ const bySourceColumn = new Map();
+ corridorCandidates.forEach((edge) => {
+ const src = rects.get(edge.fromId);
+ if (!src) return;
+ const key = sourceColumnKey(src);
+ const list = bySourceColumn.get(key) ?? [];
+ if (!list.includes(edge.fromId)) list.push(edge.fromId);
+ bySourceColumn.set(key, list);
+ });
+
+ bySourceColumn.forEach((sourceIds) => {
+ if (sourceIds.length <= 1) return;
+
+ const sortedSources = [...sourceIds].sort((a, b) => {
+ const asy = rects.get(a)?.y ?? 0;
+ const bsy = rects.get(b)?.y ?? 0;
+ return asy - bsy || a.localeCompare(b);
+ });
+
+ const spread = (sortedSources.length - 1) * CORRIDOR_STEP;
+
+ sortedSources.forEach((fromId, index) => {
+ const exitOffset = HORIZONTAL_STUB - spread / 2 + index * CORRIDOR_STEP;
+ corridorCandidates
+ .filter((e) => e.fromId === fromId)
+ .forEach((edge) => {
+ const key = edgeKey(edge.fromId, edge.toId);
+ const meta = corridorMetaByKey.get(key);
+ if (!meta) return;
+ corridorMetaByKey.set(key, {
+ ...meta,
+ corridorExitOffset: exitOffset,
+ });
+ });
+ });
+ });
+
+ return routed.map((edge) => {
+ const meta = corridorMetaByKey.get(edgeKey(edge.fromId, edge.toId));
+ if (!meta) return edge;
+ return {
+ ...edge,
+ pathMode: "corridor",
+ corridorY: meta.corridorY,
+ corridorExitOffset: meta.corridorExitOffset,
+ corridorEntryOffset: meta.corridorEntryOffset,
+ corridorBranchOffset: meta.corridorBranchOffset,
+ // Shared trunk fans already leave from out-0 with zero smooth offset.
+ offset: (outDegree.get(edge.fromId) ?? 0) > 1 ? 0 : edge.offset,
+ sourceHandle:
+ (outDegree.get(edge.fromId) ?? 0) > 1
+ ? `${TRACE_FLOW_HANDLE_OUT}-0`
+ : edge.sourceHandle,
+ };
+ });
+};
+
+/**
+ * Orthogonal path: exit source → drop to corridor → travel →
+ * (optional late branch) → rise to target.
+ */
+export const buildTraceFlowCorridorPath = (
+ sourceX: number,
+ sourceY: number,
+ targetX: number,
+ targetY: number,
+ corridorY: number,
+ corridorEntryOffset?: number,
+ corridorExitOffset?: number,
+ corridorBranchOffset?: number,
+): string => {
+ const exitX = sourceX + (corridorExitOffset ?? HORIZONTAL_STUB);
+ const entryX = targetX + (corridorEntryOffset ?? -HORIZONTAL_STUB);
+ const midY = corridorY;
+ const branchX =
+ corridorBranchOffset != null ? sourceX + corridorBranchOffset : entryX;
+
+ // Keep branch between exit and entry so the trunk never backtracks oddly.
+ const clampedBranchX = Math.min(
+ Math.max(branchX, Math.min(exitX, entryX)),
+ Math.max(exitX, entryX),
+ );
+
+ if (Math.abs(clampedBranchX - entryX) < 0.5) {
+ return [
+ `M ${sourceX} ${sourceY}`,
+ `L ${exitX} ${sourceY}`,
+ `L ${exitX} ${midY}`,
+ `L ${entryX} ${midY}`,
+ `L ${entryX} ${targetY}`,
+ `L ${targetX} ${targetY}`,
+ ].join(" ");
+ }
+
+ return [
+ `M ${sourceX} ${sourceY}`,
+ `L ${exitX} ${sourceY}`,
+ `L ${exitX} ${midY}`,
+ `L ${clampedBranchX} ${midY}`,
+ `L ${entryX} ${midY}`,
+ `L ${entryX} ${targetY}`,
+ `L ${targetX} ${targetY}`,
+ ].join(" ");
+};
+
+/** Vertical position (%) for handle index among `count` siblings on one node side. */
+export const traceFlowHandleTopPercent = (index: number, count: number): string => {
+ if (count <= 1) return "50%";
+ return `${((index + 1) / (count + 1)) * 100}%`;
+};
diff --git a/src/components/ItemTracing/traceFlowLayout.ts b/src/components/ItemTracing/traceFlowLayout.ts
new file mode 100644
index 0000000..9469290
--- /dev/null
+++ b/src/components/ItemTracing/traceFlowLayout.ts
@@ -0,0 +1,200 @@
+import {
+ COLUMN_WIDTH_MIN,
+ HEADER_HEIGHT,
+ LANE_GAP,
+ LANE_MIN_HEIGHT,
+ NODE_GAP,
+ NODE_HEIGHT,
+ NODE_WIDTH,
+ NODE_WIDTH_BRANCH,
+} from "./traceFlowConstants";
+import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout";
+import { isFlowGroupContainer } from "./traceDoGroupLayout";
+
+export const CELL_PAD_X = 10;
+export const CELL_PAD_Y = 10;
+
+export type CellPlacement = { x: number; y: number; compact: boolean };
+
+const horizWidth = (count: number, nodeW: number) =>
+ count * nodeW + Math.max(0, count - 1) * NODE_GAP;
+
+const defaultInnerCellWidth = () => COLUMN_WIDTH_MIN - CELL_PAD_X * 2;
+
+const nodeLayoutSize = (
+ node: TraceGraphLayoutNode,
+ compact = false,
+): { width: number; height: number } => {
+ if (isFlowGroupContainer(node)) {
+ return {
+ width: node.groupBoxWidth ?? NODE_WIDTH,
+ height: node.groupBoxHeight ?? NODE_HEIGHT,
+ };
+ }
+ return {
+ width: compact ? NODE_WIDTH_BRANCH : NODE_WIDTH,
+ height: NODE_HEIGHT,
+ };
+};
+
+const stackedCellHeight = (members: TraceGraphLayoutNode[]): number =>
+ members.reduce((sum, node, index) => {
+ const { height } = nodeLayoutSize(node);
+ return sum + height + (index > 0 ? NODE_GAP : 0);
+ }, 0);
+
+/** Group boxes have variable height — never tile them horizontally with other cards. */
+const requiresStackedLayout = (members: TraceGraphLayoutNode[]): boolean =>
+ members.length > 1 && members.some(isFlowGroupContainer);
+
+const layoutStackedMembers = (
+ sorted: TraceGraphLayoutNode[],
+ innerW: number,
+ compact: boolean,
+): Map => {
+ const result = new Map();
+ let y = CELL_PAD_Y;
+ sorted.forEach((node) => {
+ const { width, height } = nodeLayoutSize(node, compact);
+ result.set(node.id, {
+ x: Math.max(0, (innerW - width) / 2),
+ y,
+ compact,
+ });
+ y += height + NODE_GAP;
+ });
+ return result;
+};
+
+/** Minimum phase-slot width for a cell's top-level members (excludes DO group children). */
+export const cellWidthRequirement = (members: TraceGraphLayoutNode[]): number => {
+ const sorted = [...members].sort(sortNodesInPhase);
+ const n = sorted.length;
+ if (n === 0) return COLUMN_WIDTH_MIN;
+
+ if (n === 1 && isFlowGroupContainer(sorted[0]!)) {
+ return Math.max(COLUMN_WIDTH_MIN, (sorted[0]!.groupBoxWidth ?? NODE_WIDTH) + CELL_PAD_X * 2);
+ }
+
+ if (n === 1) return COLUMN_WIDTH_MIN;
+
+ if (requiresStackedLayout(sorted)) {
+ const maxW = Math.max(...sorted.map((node) => nodeLayoutSize(node).width));
+ return Math.max(COLUMN_WIDTH_MIN, maxW + CELL_PAD_X * 2);
+ }
+
+ const innerMin = COLUMN_WIDTH_MIN - CELL_PAD_X * 2;
+ const branchInner = horizWidth(n, NODE_WIDTH_BRANCH);
+ const fullInner = horizWidth(n, NODE_WIDTH);
+ const contentInner = Math.max(branchInner, fullInner, innerMin);
+ return contentInner + CELL_PAD_X * 2;
+};
+
+/** Lay out nodes sharing the same date column + phase lane (side-by-side or vertical stack). */
+export const layoutCellMembers = (
+ members: TraceGraphLayoutNode[],
+ innerCellWidth = defaultInnerCellWidth(),
+): Map => {
+ const sorted = [...members].sort(sortNodesInPhase);
+ const result = new Map();
+ const n = sorted.length;
+ if (n === 0) return result;
+
+ const innerW = innerCellWidth;
+
+ if (n === 1 && isFlowGroupContainer(sorted[0]!)) {
+ const w = sorted[0]!.groupBoxWidth ?? NODE_WIDTH;
+ result.set(sorted[0]!.id, {
+ x: Math.max(0, (innerW - w) / 2),
+ y: CELL_PAD_Y,
+ compact: false,
+ });
+ return result;
+ }
+
+ if (n === 1) {
+ result.set(sorted[0].id, {
+ x: (innerW - NODE_WIDTH) / 2,
+ y: CELL_PAD_Y,
+ compact: false,
+ });
+ return result;
+ }
+
+ if (requiresStackedLayout(sorted)) {
+ return layoutStackedMembers(sorted, innerW, false);
+ }
+
+ let horizontal = false;
+ let nodeW = NODE_WIDTH_BRANCH;
+ if (horizWidth(n, NODE_WIDTH_BRANCH) <= innerW) {
+ horizontal = true;
+ nodeW = NODE_WIDTH_BRANCH;
+ } else if (horizWidth(n, NODE_WIDTH) <= innerW) {
+ horizontal = true;
+ nodeW = NODE_WIDTH;
+ }
+
+ if (horizontal) {
+ const totalW = horizWidth(n, nodeW);
+ const startX = (innerW - totalW) / 2;
+ sorted.forEach((node, i) => {
+ result.set(node.id, {
+ x: startX + i * (nodeW + NODE_GAP),
+ y: CELL_PAD_Y,
+ compact: true,
+ });
+ });
+ return result;
+ }
+
+ return layoutStackedMembers(sorted, innerW, true);
+};
+
+export const cellContentHeight = (members: TraceGraphLayoutNode[]): number => {
+ const sorted = [...members].sort(sortNodesInPhase);
+ const memberCount = sorted.length;
+ if (memberCount <= 0) return LANE_MIN_HEIGHT;
+ if (memberCount === 1) {
+ const sole = sorted[0]!;
+ if (isFlowGroupContainer(sole) && sole.groupBoxHeight) {
+ return CELL_PAD_Y * 2 + sole.groupBoxHeight;
+ }
+ return CELL_PAD_Y * 2 + NODE_HEIGHT;
+ }
+
+ if (requiresStackedLayout(sorted)) {
+ return CELL_PAD_Y * 2 + stackedCellHeight(sorted);
+ }
+
+ const innerW = defaultInnerCellWidth();
+ const fitsHoriz =
+ horizWidth(memberCount, NODE_WIDTH_BRANCH) <= innerW ||
+ horizWidth(memberCount, NODE_WIDTH) <= innerW;
+
+ if (fitsHoriz) return CELL_PAD_Y * 2 + NODE_HEIGHT;
+ return CELL_PAD_Y * 2 + stackedCellHeight(sorted);
+};
+
+export const computeLaneTops = (
+ laneCount: number,
+ cells: Map,
+): { laneTops: number[]; laneHeights: number[]; totalHeight: number } => {
+ const laneHeights = Array.from({ length: laneCount }, () => LANE_MIN_HEIGHT);
+
+ cells.forEach((members, key) => {
+ const laneIndex = Number(key.split(":")[0]);
+ const cellH = cellContentHeight(members);
+ laneHeights[laneIndex] = Math.max(laneHeights[laneIndex] ?? LANE_MIN_HEIGHT, cellH);
+ });
+
+ const laneTops: number[] = [];
+ let y = HEADER_HEIGHT;
+ for (let i = 0; i < laneCount; i++) {
+ laneTops[i] = y;
+ y += laneHeights[i] ?? LANE_MIN_HEIGHT;
+ if (i < laneCount - 1) y += LANE_GAP;
+ }
+
+ return { laneTops, laneHeights, totalHeight: y + 16 };
+};
diff --git a/src/components/ItemTracing/traceFlowNodeUtils.ts b/src/components/ItemTracing/traceFlowNodeUtils.ts
new file mode 100644
index 0000000..30a1a1d
--- /dev/null
+++ b/src/components/ItemTracing/traceFlowNodeUtils.ts
@@ -0,0 +1,223 @@
+import { TraceGraphNodeKind } from "./buildTraceGraphNodes";
+import { TraceGraphPhase } from "./traceGraphLayout";
+
+/**
+ * Palette aligned with phase/category so kinds in different lanes do not all
+ * collapse onto the same chip color (outbound vs pick vs scrap vs fail).
+ */
+export const kindColor = (
+ kind: TraceGraphNodeKind,
+):
+ | "success"
+ | "warning"
+ | "info"
+ | "secondary"
+ | "default"
+ | "primary"
+ | "error" => {
+ switch (kind) {
+ case "PURCHASE":
+ case "RECEIPT":
+ case "MATERIAL_IN":
+ case "OPEN":
+ case "IN":
+ return "success";
+ case "QC":
+ case "MATERIAL_QC":
+ return "info";
+ case "FAIL":
+ return "error";
+ case "MATERIAL_PICK":
+ case "PICK_GROUP":
+ return "secondary";
+ case "PRODUCTION_STEP":
+ case "JO_CREATED":
+ case "BYPRODUCT":
+ return "primary";
+ case "SCRAP":
+ return "warning";
+ case "DEFECT":
+ case "EXPIRED":
+ return "error";
+ case "PUTAWAY":
+ case "TRANSFER":
+ case "REPACK":
+ return "primary";
+ case "STOCK_TAKE":
+ return "secondary";
+ case "ADJUSTMENT":
+ case "DEPLETED":
+ return "default";
+ case "DO_OUT":
+ case "DO_GROUP":
+ case "REPLENISHMENT_CREATED":
+ case "OUT":
+ return "warning";
+ case "JO_OUT":
+ case "PO_OUT":
+ return "secondary";
+ case "RETURN":
+ return "error";
+ default:
+ return "default";
+ }
+};
+
+export const kindLabelKey = (kind: TraceGraphNodeKind): string => {
+ switch (kind) {
+ case "MATERIAL_IN":
+ return "nodeMaterialIn";
+ case "JO_CREATED":
+ return "nodeJoCreated";
+ case "MATERIAL_QC":
+ return "tabQc";
+ case "MATERIAL_PICK":
+ return "nodeMaterialPick";
+ case "PRODUCTION_STEP":
+ return "nodeProductionStep";
+ case "BYPRODUCT":
+ return "nodeByproduct";
+ case "SCRAP":
+ return "nodeScrap";
+ case "DEFECT":
+ return "nodeDefect";
+ case "OPEN":
+ return "nodeOpen";
+ case "FAIL":
+ return "nodeFail";
+ case "DO_OUT":
+ return "nodeDoOut";
+ case "REPLENISHMENT_CREATED":
+ return "nodeReplenishmentCreated";
+ case "JO_OUT":
+ return "nodeJoOut";
+ case "PO_OUT":
+ return "nodePoOut";
+ case "DO_GROUP":
+ return "nodeDoGroup";
+ case "PICK_GROUP":
+ return "nodePickGroup";
+ case "RETURN":
+ return "nodeReturn";
+ case "REPACK":
+ return "nodeRepack";
+ case "PURCHASE":
+ return "nodePurchase";
+ case "RECEIPT":
+ return "nodeReceipt";
+ case "PUTAWAY":
+ return "nodePutaway";
+ case "IN":
+ return "directionIn";
+ case "OUT":
+ return "directionOut";
+ case "QC":
+ return "tabQc";
+ case "STOCK_TAKE":
+ return "nodeStockTake";
+ case "ADJUSTMENT":
+ return "nodeAdjustment";
+ case "TRANSFER":
+ return "nodeTransfer";
+ case "EXPIRED":
+ return "nodeExpired";
+ case "DEPLETED":
+ return "nodeDepleted";
+ default:
+ return "type";
+ }
+};
+
+export const phaseLabelKey = (phase: TraceGraphPhase): string => {
+ switch (phase) {
+ case "MATERIAL_PICK":
+ return "phaseMaterialPick";
+ case "PRODUCTION":
+ return "phaseProduction";
+ case "PURCHASE":
+ return "phasePurchase";
+ case "INBOUND":
+ return "phaseInbound";
+ case "QC":
+ return "phaseQc";
+ case "PUTAWAY":
+ return "phasePutaway";
+ case "WAREHOUSE":
+ return "phaseWarehouse";
+ case "OUTBOUND":
+ return "phaseOutbound";
+ case "STOCK_TAKE":
+ return "phaseStockTake";
+ default:
+ return "type";
+ }
+};
+
+export const phaseAccent = (phase: TraceGraphPhase): string => {
+ switch (phase) {
+ case "MATERIAL_PICK":
+ return "#f9a825";
+ case "PRODUCTION":
+ return "#6a1b9a";
+ case "PURCHASE":
+ return "#33691e";
+ case "INBOUND":
+ return "#2e7d32";
+ case "QC":
+ return "#0288d1";
+ case "PUTAWAY":
+ return "#00897b";
+ case "WAREHOUSE":
+ return "#7b1fa2";
+ case "OUTBOUND":
+ return "#ed6c02";
+ case "STOCK_TAKE":
+ return "#5c6bc0";
+ default:
+ return "#757575";
+ }
+};
+
+/** MUI Chip color for a phase — matches the node chips typically shown in that lane. */
+export const phaseChipColor = (
+ phase: TraceGraphPhase,
+):
+ | "success"
+ | "warning"
+ | "info"
+ | "secondary"
+ | "default"
+ | "primary"
+ | "error" => {
+ switch (phase) {
+ case "PURCHASE":
+ case "INBOUND":
+ return "success";
+ case "QC":
+ return "info";
+ case "PUTAWAY":
+ case "PRODUCTION":
+ case "WAREHOUSE":
+ return "primary";
+ case "MATERIAL_PICK":
+ case "STOCK_TAKE":
+ return "secondary";
+ case "OUTBOUND":
+ return "warning";
+ default:
+ return "default";
+ }
+};
+
+export const minimapNodeColor = (kind: TraceGraphNodeKind): string => {
+ const map: Record = {
+ success: "#2e7d32",
+ warning: "#ed6c02",
+ info: "#0288d1",
+ secondary: "#5c6bc0",
+ primary: "#7b1fa2",
+ default: "#9e9e9e",
+ error: "#d32f2f",
+ };
+ return map[kindColor(kind)] ?? "#9e9e9e";
+};
diff --git a/src/components/ItemTracing/traceGraphLabels.ts b/src/components/ItemTracing/traceGraphLabels.ts
new file mode 100644
index 0000000..8f5d762
--- /dev/null
+++ b/src/components/ItemTracing/traceGraphLabels.ts
@@ -0,0 +1,291 @@
+import { TFunction } from "i18next";
+import { createTraceLabelTranslator } from "./traceLabelUtils";
+import { formatQty } from "./traceQtyUtils";
+import type { JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes";
+import type { ProductionGraphLabels } from "./buildProductionGraphNodes";
+import type { ExtendedTraceGraphLabels } from "./buildExtendedTraceGraphNodes";
+import type { TraceGraphDetailLabels } from "./buildTraceGraphNodes";
+
+export type TraceGraphCompileLabels = TraceGraphDetailLabels &
+ JoPreludeGraphLabels &
+ ProductionGraphLabels &
+ ExtendedTraceGraphLabels & {
+ flowDoGroupTitle?: (count: number) => string;
+ flowPickGroupTitle?: (pickOrderCode: string, count: number) => string;
+ };
+
+export const buildTraceGraphLabels = (
+ t: TFunction,
+ lotUom: string,
+): TraceGraphCompileLabels => {
+ const tr = createTraceLabelTranslator(t);
+ return {
+ tr,
+ nodeQcPass: t("nodeQcPass"),
+ nodeQcFail: t("nodeQcFail"),
+ nodeReceipt: t("nodeReceipt"),
+ nodePurchase: t("nodePurchase"),
+ nodePutaway: t("nodePutaway"),
+ nodePutawayTransfer: t("nodePutawayTransfer"),
+ putawayTransferDetail: t("putawayTransferInboundDetail"),
+ nodeStockTake: t("nodeStockTake"),
+ nodeAdjustment: t("nodeAdjustment"),
+ nodeTransfer: t("nodeTransfer"),
+ nodeMaterialIn: t("nodeMaterialIn"),
+ nodeJoCreated: t("nodeJoCreated"),
+ nodeMaterialPick: t("nodeMaterialPick"),
+ nodeProductionStep: t("nodeProductionStep"),
+ nodeByproduct: t("nodeByproduct"),
+ nodeScrap: t("nodeScrap"),
+ nodeDefect: t("nodeDefect"),
+ detailProcessOutputQty: t("processOutputQty"),
+ detailProcessScrapQty: t("processScrapQty"),
+ detailProcessDefectQty: t("processDefectQty"),
+ nodeOpen: t("nodeOpen"),
+ nodeFail: t("nodeFail"),
+ nodeDoOut: t("nodeDoOut"),
+ nodeReplenishmentCreated: t("nodeReplenishmentCreated"),
+ nodeJoOut: t("nodeJoOut"),
+ nodePoOut: t("nodePoOut"),
+ doOutboundExtra: t("doOutboundExtra"),
+ doOutboundReplenish: t("doOutboundReplenish"),
+ detailDoOutboundKind: t("detailDoOutboundKind"),
+ flowDoGroupTitle: (count: number) => t("flowDoGroupTitle", { count }),
+ flowPickGroupTitle: (pickOrderCode: string, count: number) =>
+ t("flowPickGroupTitle", { pickOrderCode, count }),
+ nodeReturn: t("nodeReturn"),
+ nodeRepack: t("nodeRepack"),
+ traceRepackLot: t("traceRepackLot"),
+ nodeExpired: t("nodeExpired"),
+ nodeDepleted: t("nodeDepleted"),
+ directionIn: t("directionIn"),
+ directionOut: t("directionOut"),
+ formatQcSubtitle: (failQty: number, acceptedQty: number) =>
+ `${t("failQty")}: ${formatQty(failQty, lotUom)} / ${t("acceptedQty")}: ${formatQty(acceptedQty, lotUom)}`,
+ detailQty: t("qty"),
+ detailTime: t("timestamp"),
+ detailHandler: t("handler"),
+ detailStockTaker: t("stockTaker"),
+ detailApprover: t("approver"),
+ detailWarehouse: t("from"),
+ detailRemarks: t("remarks"),
+ detailFailCategory: t("detailFailCategory"),
+ detailProcessDescription: t("detailProcessDescription"),
+ detailType: t("type"),
+ detailRef: t("ref"),
+ detailStockTakeCode: t("stockTakeCode"),
+ detailAdjustmentRef: t("detailAdjustmentRef"),
+ detailReplenishmentCode: t("detailReplenishmentCode"),
+ detailReason: t("detailReason"),
+ detailReturnRef: t("detailReturnRef"),
+ detailSourceDoc: t("detailSourceDoc"),
+ detailPurchaseOrderNo: t("detailPurchaseOrderNo"),
+ detailInboundRef: t("detailInboundRef"),
+ detailInboundSiNo: t("detailInboundSiNo"),
+ detailPutawayBin: t("detailPutawayBin"),
+ detailDirection: t("detailDirection"),
+ detailStatus: t("status"),
+ detailSupplier: t("supplier"),
+ detailMaterial: t("material"),
+ detailItemCode: t("detailItemCode"),
+ detailItemName: t("detailItemName"),
+ detailLot: t("lotNo"),
+ detailAcceptedQty: t("acceptedQty"),
+ detailFailQty: t("failQty"),
+ detailQcCriteria: t("detailQcCriteria"),
+ detailQcType: t("detailQcType"),
+ detailQcUnknownItem: t("detailQcUnknownItem"),
+ detailFrom: t("from"),
+ detailTo: t("to"),
+ detailVariance: t("variance"),
+ detailBefore: t("before"),
+ detailAfter: t("after"),
+ detailStockTakeFirstCount: t("stockTakeStageFirstCount"),
+ detailStockTakeSecondCount: t("stockTakeStageSecondCount"),
+ detailStockTakeApproverCount: t("stockTakeStageApproverCount"),
+ detailScrapQty: t("scrapQty"),
+ detailDefectQty: t("defectQty"),
+ detailEquipment: t("equipment"),
+ detailProcessStep: t("processStep"),
+ detailStepMaterials: t("detailStepMaterials"),
+ detailAssignedStep: t("detailAssignedStep"),
+ detailPickTargetDate: t("detailPickTargetDate"),
+ detailProductLotNo: t("productLotNo"),
+ deliveryOrder: t("deliveryOrder"),
+ deliveryNoteCode: t("deliveryNoteCode"),
+ ticketNo: t("ticketNo"),
+ pickOrder: t("pickOrder"),
+ jobOrder: t("jobOrder"),
+ expiryDate: t("expiryDate"),
+ totalAvailable: t("totalAvailable"),
+ itemCode: t("itemCode"),
+ detailOrderQty: t("detailOrderQty"),
+ detailPutAwayQty: t("detailPutAwayQty"),
+ detailPurchaseUnit: t("detailPurchaseUnit"),
+ detailSupplyTo: t("detailSupplyTo"),
+ detailStockTakeRound: t("detailStockTakeRound"),
+ detailStockTakeSection: t("detailStockTakeSection"),
+ detailLocation: t("detailLocation"),
+ categoryPurchase: t("categoryPurchase"),
+ categoryProduction: t("categoryProduction"),
+ categoryInbound: t("phaseInbound"),
+ categoryReceipt: t("categoryReceipt"),
+ categoryPutaway: t("phasePutaway"),
+ categoryTransfer: t("phaseWarehouse"),
+ categoryStockTake: t("phaseStockTake"),
+ categoryOpen: t("categoryOpen"),
+ categoryAdjustment: t("nodeAdjustment"),
+ categoryPick: t("phaseMaterialPick"),
+ categoryQc: t("phaseQc"),
+ categoryOutbound: t("phaseOutbound"),
+ categoryTerminal: t("categoryTerminal"),
+ processingStatus: t("processingStatus"),
+ matchStatus: t("matchStatus"),
+ };
+};
+
+/** Minimal labels for unit tests (no i18n). */
+export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => {
+ const identity = (s: string | null | undefined) => s?.trim() || "—";
+ const tr = {
+ refType: identity,
+ movementType: identity,
+ direction: (c: string | null | undefined) =>
+ c?.toUpperCase() === "OUT" ? "Out" : c?.toUpperCase() === "IN" ? "In" : identity(c),
+ stockInStatus: identity,
+ adjustmentType: identity,
+ usageType: identity,
+ failType: identity,
+ productionStatus: identity,
+ pickStatus: identity,
+ joStatus: identity,
+ qcType: () => "IQC",
+ qcPassed: (p: boolean) => (p ? "Pass" : "Fail"),
+ putawayShelfStatus: (phase: "pending" | "completed") =>
+ phase === "completed" ? "Put away done" : "Pending put-away",
+ processingStatus: identity,
+ matchStatus: identity,
+ lotLineStatus: identity,
+ };
+ return {
+ tr,
+ nodeQcPass: "QC Pass",
+ nodeQcFail: "QC Fail",
+ nodeReceipt: "Receipt",
+ nodePurchase: "Purchase",
+ nodePutaway: "Putaway",
+ nodePutawayTransfer: "Transfer inbound",
+ putawayTransferDetail: "Inbound at target warehouse after transfer (auto-completed, not PO put-away queue)",
+ nodeStockTake: "Stock take",
+ nodeAdjustment: "Adjustment",
+ nodeTransfer: "Transfer",
+ nodeMaterialIn: "Material in",
+ nodeJoCreated: "JO created",
+ nodeMaterialPick: "Material pick",
+ nodeProductionStep: "Production step",
+ nodeByproduct: "Byproduct",
+ nodeScrap: "Scrap",
+ nodeDefect: "Defect",
+ detailProcessOutputQty: "Process output",
+ detailProcessScrapQty: "Scrap quantity",
+ detailProcessDefectQty: "Defect quantity",
+ nodeOpen: "Opening",
+ nodeFail: "Pick fail",
+ nodeDoOut: "DO PO",
+ nodeReplenishmentCreated: "Replenishment created",
+ nodeJoOut: "JO PO",
+ nodePoOut: "PO pick",
+ doOutboundExtra: "Add-on",
+ doOutboundReplenish: "Replenishment",
+ detailDoOutboundKind: "Outbound type",
+ flowDoGroupTitle: (count) => `DO × ${count}`,
+ flowPickGroupTitle: (code, count) => `Pick · ${code} × ${count}`,
+ nodeReturn: "Return",
+ nodeRepack: "Repack",
+ traceRepackLot: "Trace lot",
+ nodeExpired: "Expired",
+ nodeDepleted: "Depleted",
+ directionIn: "In",
+ directionOut: "Out",
+ formatQcSubtitle: (f, a) => `Fail ${f} / Accepted ${a}`,
+ detailQty: "Qty",
+ detailTime: "Time",
+ detailHandler: "Handler",
+ detailStockTaker: "First counter",
+ detailApprover: "Approver",
+ detailWarehouse: "Warehouse",
+ detailRemarks: "Remarks",
+ detailFailCategory: "Fail category",
+ detailProcessDescription: "Step description",
+ detailType: "Type",
+ detailRef: "Doc no.",
+ detailStockTakeCode: "Stock Take #",
+ detailAdjustmentRef: "Adjustment #",
+ detailReplenishmentCode: "Replenishment #",
+ detailReason: "Reason",
+ detailReturnRef: "Return #",
+ detailSourceDoc: "Source doc #",
+ detailPurchaseOrderNo: "PO no.",
+ detailInboundRef: "Inbound SI",
+ detailInboundSiNo: "Inbound SI no.",
+ detailPutawayBin: "Bin",
+ detailDirection: "Direction",
+ detailStatus: "Status",
+ detailSupplier: "Supplier",
+ detailMaterial: "Material",
+ detailItemCode: "Item code",
+ detailItemName: "Item name",
+ detailLot: "Lot",
+ detailAcceptedQty: "Accepted",
+ detailFailQty: "Fail",
+ detailQcCriteria: "Criteria",
+ detailQcType: "QC type",
+ detailQcUnknownItem: "Unknown",
+ detailFrom: "From",
+ detailTo: "To",
+ detailVariance: "Variance",
+ detailBefore: "Book qty",
+ detailAfter: "Accepted qty",
+ detailStockTakeFirstCount: "First count",
+ detailStockTakeSecondCount: "Re-count",
+ detailStockTakeApproverCount: "Approver count",
+ detailScrapQty: "Scrap",
+ detailDefectQty: "Defect",
+ detailEquipment: "Equipment",
+ detailProcessStep: "Step",
+ detailStepMaterials: "Step materials",
+ detailAssignedStep: "Assigned step",
+ detailPickTargetDate: "Target date",
+ detailProductLotNo: "Product lot",
+ deliveryOrder: "DO",
+ deliveryNoteCode: "DN",
+ ticketNo: "Ticket",
+ pickOrder: "Pick",
+ jobOrder: "Job order",
+ expiryDate: "Expiry",
+ totalAvailable: "Available",
+ itemCode: "Item code",
+ detailOrderQty: "Order qty",
+ detailPutAwayQty: "Putaway qty",
+ detailPurchaseUnit: "Purchase unit",
+ detailSupplyTo: "Supply to",
+ detailStockTakeRound: "Stock take round",
+ detailStockTakeSection: "Stock take section",
+ detailLocation: "Location",
+ categoryPurchase: "Purchase",
+ categoryProduction: "Production",
+ categoryInbound: "Inbound",
+ categoryReceipt: "Receipt",
+ categoryPutaway: "Putaway",
+ categoryTransfer: "Warehouse",
+ categoryStockTake: "Stock take",
+ categoryOpen: "Opening",
+ categoryAdjustment: "Adjustment",
+ categoryPick: "Pick",
+ categoryQc: "QC",
+ categoryOutbound: "Outbound",
+ categoryTerminal: "Terminal",
+ processingStatus: "Processing status",
+ matchStatus: "Match status",
+ };
+};
diff --git a/src/components/ItemTracing/traceGraphLayout.ts b/src/components/ItemTracing/traceGraphLayout.ts
new file mode 100644
index 0000000..1e2a703
--- /dev/null
+++ b/src/components/ItemTracing/traceGraphLayout.ts
@@ -0,0 +1,402 @@
+import dayjs from "dayjs";
+import { ItemLotTraceResponse } from "@/app/api/itemTracing";
+import { COLUMN_WIDTH_MIN } from "./traceFlowConstants";
+import {
+ buildTraceGraphNodes,
+ TraceGraphDetailLabels,
+ TraceGraphNode,
+} from "./buildTraceGraphNodes";
+import { buildJoPreludeGraphNodes, JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes";
+import {
+ buildProductionGraphNodes,
+ ProductionGraphLabels,
+} from "./buildProductionGraphNodes";
+import {
+ buildExtendedTraceGraphNodes,
+ ExtendedTraceGraphLabels,
+} from "./buildExtendedTraceGraphNodes";
+import { buildAllLocationBlockGraphNodes } from "./buildLocationBlockGraphNodes";
+import { applyFlowNodeGrouping, isDoGroupChild, regroupLayoutCells } from "./traceDoGroupLayout";
+import { cellWidthRequirement } from "./traceFlowLayout";
+import {
+ materialInputsHaveProduction,
+ phaseFromKind,
+ sortNodesInPhase,
+ sortPhasesByEarliestEvent,
+} from "./traceGraphSemantics";
+
+export {
+ buildTraceFlowEdgePairs,
+ materialInputsHaveProduction,
+ phaseFromKind,
+ sortNodesInPhase,
+ sortPhasesByEarliestEvent,
+} from "./traceGraphSemantics";
+
+export type PreProductionPhase = "MATERIAL_PICK";
+
+export type ProductionPhase = "PRODUCTION";
+
+export type FgTraceGraphPhase =
+ | "PURCHASE"
+ | "INBOUND"
+ | "QC"
+ | "PUTAWAY"
+ | "WAREHOUSE"
+ | "OUTBOUND"
+ | "STOCK_TAKE";
+
+/** @deprecated MATERIAL_QC merged into QC lane when prelude is shown */
+export type LegacyMaterialQcPhase = "MATERIAL_QC";
+
+export type TraceGraphPhase = PreProductionPhase | ProductionPhase | FgTraceGraphPhase;
+
+export const PRE_PRODUCTION_PHASES: PreProductionPhase[] = ["MATERIAL_PICK"];
+
+export const FG_PHASE_ORDER: FgTraceGraphPhase[] = [
+ "PURCHASE",
+ "INBOUND",
+ "QC",
+ "PUTAWAY",
+ "WAREHOUSE",
+ "STOCK_TAKE",
+ "OUTBOUND",
+];
+
+/** Prelude rows use one shared QC lane (原料 + 成品品檢). */
+export const PRELUDE_PHASE_ORDER = (
+ hasProduction: boolean,
+): TraceGraphPhase[] => {
+ const phases: TraceGraphPhase[] = [
+ "PURCHASE",
+ "INBOUND",
+ "QC",
+ "PUTAWAY",
+ "MATERIAL_PICK",
+ ];
+ if (hasProduction) phases.push("PRODUCTION");
+ phases.push("WAREHOUSE", "STOCK_TAKE", "OUTBOUND");
+ return phases;
+};
+
+export const getPhaseOrder = (hasPrelude: boolean, hasProduction: boolean): TraceGraphPhase[] => {
+ if (hasPrelude) return PRELUDE_PHASE_ORDER(hasProduction);
+ const phases: TraceGraphPhase[] = [...FG_PHASE_ORDER];
+ if (hasProduction) phases.splice(1, 0, "PRODUCTION");
+ return phases;
+};
+
+/** @deprecated use layout.phaseOrder */
+export const PHASE_ORDER: TraceGraphPhase[] = FG_PHASE_ORDER;
+
+/** @deprecated use layout.laneCount */
+export const LANE_COUNT = FG_PHASE_ORDER.length;
+
+export interface TraceGraphLayoutNode extends TraceGraphNode {
+ phase: TraceGraphPhase;
+ column: number;
+ dayKey: string;
+ laneIndex: number;
+ sequenceIndex: number;
+ branchIndex: number;
+ branchSize: number;
+ /** Slot index within the day column (one dynamic phase slot per phase on that day). */
+ dayPhaseStaggerIndex: number;
+}
+
+export interface DayColumnLayout {
+ columnStarts: number[];
+ columnWidths: number[];
+ /** Width of each phase slot within a calendar day (same order as phases on that day). */
+ phaseSlotWidths: number[][];
+ /** Start X offset of each phase slot within a calendar day. */
+ phaseSlotStarts: number[][];
+ timelineWidth: number;
+}
+
+export interface TraceGraphLayout {
+ nodes: TraceGraphLayoutNode[];
+ columnCount: number;
+ columnDates: string[];
+ dayColumns: DayColumnLayout;
+ cellGroups: TraceGraphLayoutNode[][];
+ phaseOrder: TraceGraphPhase[];
+ laneCount: number;
+ hasPrelude: boolean;
+ hasProduction: boolean;
+}
+
+export const laneIndexFromPhase = (phase: TraceGraphPhase, phaseOrder: TraceGraphPhase[]): number =>
+ phaseOrder.indexOf(phase);
+
+const cellKey = (column: number, laneIndex: number) => `${column}:${laneIndex}`;
+
+export const dayKeyFromTimestamp = (timestamp: string | null): string => {
+ if (!timestamp?.trim()) return "—";
+ const d = dayjs(timestamp);
+ if (!d.isValid()) return timestamp.slice(0, 10) || "—";
+ return d.format("YYYY-MM-DD");
+};
+
+export const phasesOnDay = (
+ nodes: TraceGraphLayoutNode[],
+ dayKey: string,
+ phaseOrder: TraceGraphPhase[],
+): TraceGraphPhase[] => {
+ const present = new Set();
+ nodes.forEach((n) => {
+ if (n.dayKey === dayKey) present.add(n.phase);
+ });
+ return sortPhasesByEarliestEvent(nodes, Array.from(present), phaseOrder, dayKey);
+};
+
+/** Widen each calendar day by chronological time slots (not phase-packed slots). */
+export const computeDayColumnLayout = (
+ columnDates: string[],
+ nodes: TraceGraphLayoutNode[],
+ _phaseOrder: TraceGraphPhase[],
+): DayColumnLayout => {
+ const visible = nodes.filter((n) => !isDoGroupChild(n));
+ const phaseSlotWidths: number[][] = [];
+ const phaseSlotStarts: number[][] = [];
+ const columnWidths: number[] = [];
+
+ columnDates.forEach((dayKey, colIndex) => {
+ const dayNodes = visible.filter((n) => n.column === colIndex && n.dayKey === dayKey);
+ const maxSlot = dayNodes.reduce((m, n) => Math.max(m, n.dayPhaseStaggerIndex), 0);
+ const slotCount = dayNodes.length === 0 ? 1 : maxSlot + 1;
+
+ const slotWidths: number[] = [];
+ for (let slot = 0; slot < slotCount; slot++) {
+ const members = dayNodes.filter((n) => n.dayPhaseStaggerIndex === slot);
+ slotWidths.push(cellWidthRequirement(members));
+ }
+
+ const starts: number[] = [];
+ let dayWidth = 0;
+ slotWidths.forEach((w) => {
+ starts.push(dayWidth);
+ dayWidth += w;
+ });
+
+ phaseSlotWidths.push(slotWidths);
+ phaseSlotStarts.push(starts);
+ columnWidths.push(Math.max(COLUMN_WIDTH_MIN, dayWidth));
+ });
+
+ const columnStarts: number[] = [];
+ let x = 0;
+ for (const w of columnWidths) {
+ columnStarts.push(x);
+ x += w;
+ }
+ return { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts, timelineWidth: x };
+};
+
+/**
+ * Within each calendar day, assign left→right slots by event time (sortKey).
+ * Avoids packing an entire phase to the left of another phase when later events
+ * in the early phase happen after earlier events in a later phase.
+ */
+export const assignChronologicalDaySlots = (nodes: TraceGraphLayoutNode[]): void => {
+ const byDay = new Map();
+ nodes.forEach((n) => {
+ if (isDoGroupChild(n)) return;
+ const list = byDay.get(n.dayKey) ?? [];
+ list.push(n);
+ byDay.set(n.dayKey, list);
+ });
+
+ byDay.forEach((dayNodes) => {
+ dayNodes.sort((a, b) => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ // Same timestamp: putaway before material ADJUSTMENT only (上架 → 庫存調整).
+ if (
+ (a.kind === "PUTAWAY" || a.kind === "ADJUSTMENT") &&
+ (b.kind === "PUTAWAY" || b.kind === "ADJUSTMENT") &&
+ a.kind !== b.kind
+ ) {
+ return a.kind === "PUTAWAY" ? -1 : 1;
+ }
+ // 建立工單 before 工單提料 group / lines in the same pick phase.
+ const pickLaneOrder = (n: TraceGraphLayoutNode) =>
+ n.kind === "JO_CREATED" ? 0 : n.kind === "PICK_GROUP" ? 1 : n.kind === "MATERIAL_PICK" ? 2 : 3;
+ const pickA = pickLaneOrder(a);
+ const pickB = pickLaneOrder(b);
+ if (pickA !== pickB) return pickA - pickB;
+ // Then canonical phase / lane order (e.g. QC before putaway).
+ if (a.laneIndex !== b.laneIndex) return a.laneIndex - b.laneIndex;
+ if (a.sequenceIndex !== b.sequenceIndex) return a.sequenceIndex - b.sequenceIndex;
+ return a.id.localeCompare(b.id);
+ });
+ dayNodes.forEach((n, i) => {
+ n.dayPhaseStaggerIndex = i;
+ n.branchIndex = 0;
+ n.branchSize = 1;
+ });
+ });
+
+ // Group children share the container's horizontal time slot.
+ const parentById = new Map(nodes.map((n) => [n.id, n]));
+ nodes.forEach((n) => {
+ if (!n.doGroupId) return;
+ const parent = parentById.get(n.doGroupId);
+ if (parent) n.dayPhaseStaggerIndex = parent.dayPhaseStaggerIndex;
+ });
+};
+
+const assignColumns = (
+ sorted: TraceGraphNode[],
+ phaseOrder: TraceGraphPhase[],
+): { columnDates: string[]; nodes: TraceGraphLayoutNode[]; cellGroups: TraceGraphLayoutNode[][] } => {
+ const dayOrder: string[] = [];
+ const columnByDayKey = new Map();
+
+ const withMeta: TraceGraphLayoutNode[] = sorted.map((node, sequenceIndex) => {
+ const dayKey = dayKeyFromTimestamp(node.timestamp);
+ let column = columnByDayKey.get(dayKey);
+ if (column === undefined) {
+ column = dayOrder.length;
+ dayOrder.push(dayKey);
+ columnByDayKey.set(dayKey, column);
+ }
+ const phase = phaseFromKind(node.kind, phaseOrder, node.traceLotNo, node.refType);
+ return {
+ ...node,
+ phase,
+ dayKey,
+ column,
+ laneIndex: laneIndexFromPhase(phase, phaseOrder),
+ sequenceIndex,
+ branchIndex: 0,
+ branchSize: 1,
+ dayPhaseStaggerIndex: 0,
+ };
+ });
+
+ // Provisional phase-based stagger (overwritten after grouping by chronological slots).
+ const staggerByDay = new Map();
+ withMeta.forEach((node) => {
+ if (!staggerByDay.has(node.dayKey)) {
+ staggerByDay.set(node.dayKey, phasesOnDay(withMeta, node.dayKey, phaseOrder));
+ }
+ const ordered = staggerByDay.get(node.dayKey)!;
+ node.dayPhaseStaggerIndex = Math.max(0, ordered.indexOf(node.phase));
+ });
+
+ const groups = new Map();
+ withMeta.forEach((node) => {
+ const key = cellKey(node.column, node.laneIndex);
+ const list = groups.get(key) ?? [];
+ list.push(node);
+ groups.set(key, list);
+ });
+
+ groups.forEach((list) => {
+ list.sort(sortNodesInPhase);
+ const size = list.length;
+ list.forEach((node, idx) => {
+ node.branchIndex = idx;
+ node.branchSize = size;
+ });
+ });
+
+ const cellGroups = Array.from(groups.values()).sort((a, b) => {
+ const na = a[0];
+ const nb = b[0];
+ if (na.column !== nb.column) return na.column - nb.column;
+ return na.laneIndex - nb.laneIndex;
+ });
+
+ return { columnDates: dayOrder, nodes: withMeta, cellGroups };
+};
+
+export const buildTraceGraphLayout = (
+ data: ItemLotTraceResponse,
+ labels: TraceGraphDetailLabels &
+ Partial &
+ Partial &
+ Partial & {
+ flowDoGroupTitle?: (count: number) => string;
+ flowPickGroupTitle?: (pickOrderCode: string, count: number) => string;
+ },
+): TraceGraphLayout => {
+ const hasPrelude = data.joPrelude != null;
+ const hasScrap = (data.productionSteps ?? []).some(
+ (s) => (s.scrapQty ?? 0) + (s.defectQty ?? 0) > 0,
+ );
+ const hasMaterialProduction =
+ data.joPrelude != null && materialInputsHaveProduction(data.joPrelude.materialInputs);
+ const hasProduction =
+ (data.productionSteps?.length ?? 0) > 0 ||
+ (data.byproductLots?.length ?? 0) > 0 ||
+ hasScrap ||
+ hasMaterialProduction;
+ const phaseOrder = getPhaseOrder(hasPrelude, hasProduction);
+ const mergedMultiLocation = (data.locationBlocks?.length ?? 0) > 0;
+ const primaryWh =
+ data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined;
+ const primaryScope = {
+ defaultWarehouseCode: primaryWh,
+ inventoryLotId: data.lot.inventoryLotId,
+ mergedMultiLocation,
+ };
+ const fgNodes = buildTraceGraphNodes(data, labels, primaryScope);
+ const preludeNodes =
+ data.joPrelude && labels.nodeMaterialIn && labels.nodeMaterialPick && labels.nodePurchase
+ ? buildJoPreludeGraphNodes(data.joPrelude, labels as JoPreludeGraphLabels)
+ : [];
+ const productionNodes =
+ hasProduction && labels.nodeProductionStep && labels.nodeScrap && labels.nodeDefect
+ ? buildProductionGraphNodes(
+ data.productionSteps ?? [],
+ data.byproductLots ?? [],
+ labels as ProductionGraphLabels,
+ data.lot.uom,
+ )
+ : [];
+ const extendedNodes =
+ labels.nodeOpen && labels.nodeFail && labels.nodeDoOut
+ ? buildExtendedTraceGraphNodes(data, labels as ExtendedTraceGraphLabels, primaryScope)
+ : [];
+ const locationNodes =
+ (data.locationBlocks?.length ?? 0) > 0 &&
+ labels.nodeOpen &&
+ labels.nodeFail &&
+ labels.nodeDoOut
+ ? buildAllLocationBlockGraphNodes(data.locationBlocks ?? [], labels as ExtendedTraceGraphLabels)
+ : [];
+ const merged = [...preludeNodes, ...productionNodes, ...extendedNodes, ...fgNodes, ...locationNodes].sort((a, b) => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ return a.id.localeCompare(b.id);
+ });
+ const { columnDates, nodes: assigned, cellGroups: _initialCellGroups } = assignColumns(
+ merged,
+ phaseOrder,
+ );
+ const groupTitleDo =
+ labels.flowDoGroupTitle ?? ((count: number) => `DO × ${count}`);
+ const groupTitlePick =
+ labels.flowPickGroupTitle ??
+ ((pickOrderCode: string, count: number) => `${pickOrderCode} × ${count}`);
+ const nodes = applyFlowNodeGrouping(assigned, {
+ flowDoGroupTitle: groupTitleDo,
+ flowPickGroupTitle: groupTitlePick,
+ });
+ assignChronologicalDaySlots(nodes);
+ const cellGroups = regroupLayoutCells(nodes);
+ const columnCount = Math.max(columnDates.length, 1);
+ const dayColumns = computeDayColumnLayout(columnDates, nodes, phaseOrder);
+
+ return {
+ nodes,
+ columnCount,
+ columnDates,
+ dayColumns,
+ cellGroups,
+ phaseOrder,
+ laneCount: phaseOrder.length,
+ hasPrelude,
+ hasProduction,
+ };
+};
diff --git a/src/components/ItemTracing/traceGraphSearch.ts b/src/components/ItemTracing/traceGraphSearch.ts
new file mode 100644
index 0000000..62b6352
--- /dev/null
+++ b/src/components/ItemTracing/traceGraphSearch.ts
@@ -0,0 +1,47 @@
+import { TraceGraphNodeKind } from "./buildTraceGraphNodes";
+import { TraceGraphLayoutNode } from "./traceGraphLayout";
+
+const normalize = (value: string) => value.trim().toLowerCase();
+
+const nodeSearchHaystack = (
+ node: TraceGraphLayoutNode,
+ kindLabel: string,
+): string => {
+ const parts: string[] = [
+ node.title,
+ node.subtitle,
+ node.refCode ?? "",
+ node.traceLotNo ?? "",
+ node.traceItemCode ?? "",
+ node.consoCode ?? "",
+ node.meta ?? "",
+ node.timestamp ?? "",
+ node.categoryLabel ?? "",
+ node.refType ?? "",
+ node.warehouseCode ?? "",
+ node.transferFromWarehouse ?? "",
+ node.transferToWarehouse ?? "",
+ kindLabel,
+ node.qty != null ? String(node.qty) : "",
+ ...node.details.flatMap((field) =>
+ [field.label, field.value, field.linkCode ?? ""].filter(Boolean),
+ ),
+ ];
+ return normalize(parts.join(" "));
+};
+
+export const searchTraceGraphNodes = (
+ nodes: TraceGraphLayoutNode[],
+ query: string,
+ kindLabel: (kind: TraceGraphNodeKind) => string,
+): string[] => {
+ const normalizedQuery = normalize(query);
+ if (!normalizedQuery) return [];
+
+ return nodes
+ .filter((node) =>
+ nodeSearchHaystack(node, kindLabel(node.kind)).includes(normalizedQuery),
+ )
+ .sort((a, b) => a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex)
+ .map((node) => node.id);
+};
diff --git a/src/components/ItemTracing/traceGraphSemantics.ts b/src/components/ItemTracing/traceGraphSemantics.ts
new file mode 100644
index 0000000..d864838
--- /dev/null
+++ b/src/components/ItemTracing/traceGraphSemantics.ts
@@ -0,0 +1,1169 @@
+import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
+import { TraceGraphNodeKind } from "./buildTraceGraphNodes";
+import { isDoGroupChild } from "./traceDoGroupLayout";
+import { isTransferInboundPutaway, warehouseCodesMatch } from "./tracePutawayUtils";
+import type { TraceGraphLayoutNode, TraceGraphPhase } from "./traceGraphLayout";
+
+export const sortNodesInPhase = (a: TraceGraphLayoutNode, b: TraceGraphLayoutNode): number => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ return a.sequenceIndex - b.sequenceIndex;
+};
+
+const earliestNodeInPhase = (
+ nodes: TraceGraphLayoutNode[],
+ phase: TraceGraphPhase,
+ dayKey?: string,
+): TraceGraphLayoutNode | null => {
+ let best: TraceGraphLayoutNode | null = null;
+ nodes.forEach((n) => {
+ if (n.phase !== phase) return;
+ if (dayKey != null && n.dayKey !== dayKey) return;
+ if (!best || n.sortKey < best.sortKey) best = n;
+ });
+ return best;
+};
+
+const earliestSortKey = (
+ nodes: TraceGraphLayoutNode[],
+ phase: TraceGraphPhase,
+ dayKey?: string,
+): number => earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?? Number.MAX_SAFE_INTEGER;
+
+/** Sort phases by earliest event time; tie-break with canonical phase order. */
+const sortPhasesByTimestamp = (
+ nodes: TraceGraphLayoutNode[],
+ phases: TraceGraphPhase[],
+ phaseOrder: TraceGraphPhase[],
+ dayKey?: string,
+): TraceGraphPhase[] =>
+ [...phases].sort((a, b) => {
+ const ta = earliestSortKey(nodes, a, dayKey);
+ const tb = earliestSortKey(nodes, b, dayKey);
+ if (ta !== tb) return ta - tb;
+ return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
+ });
+
+export const sortPhasesByEarliestEvent = (
+ nodes: TraceGraphLayoutNode[],
+ phases: TraceGraphPhase[],
+ phaseOrder: TraceGraphPhase[],
+ dayKey?: string,
+): TraceGraphPhase[] => sortPhasesByTimestamp(nodes, phases, phaseOrder, dayKey);
+
+/** FG cross-day flow: chronological across days; same calendar day keeps canonical phase order for edges. */
+const sortFgPhasesForCrossDayFlow = (
+ nodes: TraceGraphLayoutNode[],
+ phases: TraceGraphPhase[],
+ phaseOrder: TraceGraphPhase[],
+): TraceGraphPhase[] =>
+ [...phases].sort((a, b) => {
+ const nodeA = earliestNodeInPhase(nodes, a);
+ const nodeB = earliestNodeInPhase(nodes, b);
+ const ta = nodeA?.sortKey ?? Number.MAX_SAFE_INTEGER;
+ const tb = nodeB?.sortKey ?? Number.MAX_SAFE_INTEGER;
+ const sameDay =
+ nodeA != null &&
+ nodeB != null &&
+ nodeA.dayKey !== "—" &&
+ nodeA.dayKey === nodeB.dayKey;
+ if (sameDay) {
+ return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
+ }
+ if (ta !== tb) return ta - tb;
+ return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
+ });
+
+export const phaseFromKind = (
+ kind: TraceGraphNodeKind,
+ phaseOrder: TraceGraphPhase[],
+ traceLotNo?: string,
+ refType?: string | null,
+): TraceGraphPhase => {
+ const materialPrelude = Boolean(traceLotNo?.trim());
+ switch (kind) {
+ case "MATERIAL_IN":
+ return "INBOUND";
+ case "JO_CREATED":
+ // Sit with 工單提料 so 建立工單 → 工單提料 group stay in one lane.
+ if (phaseOrder.includes("MATERIAL_PICK")) return "MATERIAL_PICK";
+ return "INBOUND";
+ case "MATERIAL_QC":
+ case "QC":
+ case "FAIL":
+ return "QC";
+ case "MATERIAL_PICK":
+ return "MATERIAL_PICK";
+ case "PICK_GROUP":
+ return "MATERIAL_PICK";
+ case "PRODUCTION_STEP":
+ case "BYPRODUCT":
+ case "SCRAP":
+ case "DEFECT":
+ return "PRODUCTION";
+ case "OPEN":
+ return "INBOUND";
+ case "DO_OUT":
+ case "REPLENISHMENT_CREATED":
+ case "JO_OUT":
+ case "PO_OUT":
+ case "DO_GROUP":
+ case "RETURN":
+ return "OUTBOUND";
+ case "REPACK":
+ return "WAREHOUSE";
+ case "PURCHASE":
+ return "PURCHASE";
+ case "RECEIPT":
+ case "IN":
+ return "INBOUND";
+ case "PUTAWAY":
+ if (isTransferInboundPutaway(refType)) return "WAREHOUSE";
+ return "PUTAWAY";
+ case "TRANSFER":
+ return "WAREHOUSE";
+ case "ADJUSTMENT":
+ // Material BOM ADJ stock-in lives next to material putaway (上架 → 庫存調整).
+ return materialPrelude ? "PUTAWAY" : "WAREHOUSE";
+ case "OUT":
+ return "OUTBOUND";
+ case "STOCK_TAKE":
+ return "STOCK_TAKE";
+ case "EXPIRED":
+ case "DEPLETED":
+ return "OUTBOUND";
+ default:
+ return phaseOrder.includes("WAREHOUSE") ? "WAREHOUSE" : phaseOrder[0];
+ }
+};
+
+export const materialInputsHaveProduction = (inputs: ItemLotTraceMaterialInput[]): boolean =>
+ inputs.some(
+ (m) =>
+ (m.productionSteps?.length ?? 0) > 0 ||
+ (m.nestedJoPrelude?.materialInputs.length
+ ? materialInputsHaveProduction(m.nestedJoPrelude.materialInputs)
+ : false),
+ );
+
+const NO_FLOW_EDGE_SOURCE_KINDS = new Set(["EXPIRED", "DEPLETED"]);
+
+const NO_FLOW_EDGE_KINDS = new Set([
+ "REPACK",
+ "BYPRODUCT",
+]);
+
+const TERMINAL_TARGET_KINDS = new Set(["EXPIRED", "DEPLETED"]);
+
+const TERMINAL_PREDECESSOR_KINDS = new Set([
+ "PUTAWAY",
+ "TRANSFER",
+ "ADJUSTMENT",
+ "STOCK_TAKE",
+ "REPACK",
+ "DO_OUT",
+ "DO_GROUP",
+ "JO_OUT",
+ "PO_OUT",
+ "OUT",
+ "RETURN",
+]);
+
+const nodeScopePrefix = (node: TraceGraphLayoutNode): string => {
+ const match = /^loc-(\d+)-/.exec(node.id);
+ return match ? `loc-${match[1]}-` : "";
+};
+
+const buildTerminalStateEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+) => {
+ const terminals = nodes.filter((n) => TERMINAL_TARGET_KINDS.has(n.kind));
+ terminals.forEach((terminal) => {
+ const scope = nodeScopePrefix(terminal);
+ const scoped = nodes.filter((n) => nodeScopePrefix(n) === scope);
+ const predecessors = scoped
+ .filter((n) => TERMINAL_PREDECESSOR_KINDS.has(n.kind) && n.id !== terminal.id)
+ .sort(sortNodesInPhase);
+ const from = predecessors[predecessors.length - 1];
+ if (from) add(from, terminal);
+ });
+};
+
+const WAREHOUSE_FLOW_KINDS = new Set(["TRANSFER", "ADJUSTMENT"]);
+
+/** Predecessors that represent on-hand inventory state (skip QC / production). */
+const INVENTORY_STATE_KINDS = new Set([
+ "PUTAWAY",
+ "TRANSFER",
+ "ADJUSTMENT",
+ "OUT",
+ "DO_OUT",
+ "JO_OUT",
+ "PO_OUT",
+ "DO_GROUP",
+ "STOCK_TAKE",
+ "RETURN",
+ "RECEIPT",
+ "IN",
+ "OPEN",
+]);
+
+export const isMaterialPreludeNode = (n: TraceGraphLayoutNode): boolean =>
+ (["MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK"] as TraceGraphNodeKind[]).includes(n.kind) ||
+ (n.kind === "JO_CREATED" && Boolean(n.traceLotNo?.trim())) ||
+ (n.kind === "PUTAWAY" && Boolean(n.traceLotNo?.trim())) ||
+ (n.kind === "ADJUSTMENT" && Boolean(n.traceLotNo?.trim())) ||
+ (n.kind === "PRODUCTION_STEP" && Boolean(n.traceLotNo?.trim())) ||
+ ((n.kind === "SCRAP" || n.kind === "DEFECT") && Boolean(n.traceLotNo?.trim())) ||
+ ((n.kind === "PURCHASE" || n.kind === "RECEIPT") && Boolean(n.traceLotNo?.trim()));
+
+const materialLotKey = (n: TraceGraphLayoutNode): string | null => {
+ const lot = n.traceLotNo?.trim();
+ if (!lot) return null;
+ const item = n.traceItemCode?.trim() ?? "";
+ return `${item}::${lot}`;
+};
+
+const resolvePickFlowTarget = (
+ pick: TraceGraphLayoutNode,
+ nodes: TraceGraphLayoutNode[],
+): TraceGraphLayoutNode => {
+ const groupId = pick.doGroupId?.trim();
+ if (!groupId) return pick;
+ return nodes.find((n) => n.id === groupId) ?? pick;
+};
+
+const PICK_OR_OUTBOUND_TARGET_KINDS = new Set([
+ "MATERIAL_PICK",
+ "PICK_GROUP",
+ "DO_OUT",
+ "DO_GROUP",
+ "JO_OUT",
+ "PO_OUT",
+ "OUT",
+]);
+
+export const shouldSkipTraceFlowEdge = (
+ from: TraceGraphLayoutNode,
+ to: TraceGraphLayoutNode,
+): boolean => {
+ if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) {
+ if (to.kind === "MATERIAL_PICK" || to.kind === "PICK_GROUP") return false;
+ return true;
+ }
+ // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps
+ // except material picks (handled by dedicated pick→step edges).
+ if (
+ to.kind === "PRODUCTION_STEP" &&
+ from.kind !== "MATERIAL_PICK" &&
+ from.kind !== "PICK_GROUP" &&
+ from.kind !== "PRODUCTION_STEP"
+ ) {
+ return true;
+ }
+ if (
+ (from.kind === "QC" || from.kind === "FAIL" || from.kind === "MATERIAL_QC") &&
+ to.kind === "PRODUCTION_STEP"
+ ) {
+ return true;
+ }
+ if (from.kind === "PRODUCTION_STEP" && to.kind === "JO_CREATED") {
+ return true;
+ }
+ if (from.kind === "PUTAWAY" && (to.kind === "QC" || to.kind === "FAIL")) {
+ return true;
+ }
+ if (from.kind === "PRODUCTION_STEP" && PICK_OR_OUTBOUND_TARGET_KINDS.has(to.kind)) {
+ return true;
+ }
+ if (to.kind === "REPLENISHMENT_CREATED") {
+ // Inventory / warehouse → 建立補貨; block generic phase noise.
+ return !(
+ from.kind === "PUTAWAY" ||
+ from.kind === "TRANSFER" ||
+ from.kind === "ADJUSTMENT" ||
+ from.kind === "OPEN" ||
+ from.kind === "RECEIPT" ||
+ from.kind === "IN"
+ );
+ }
+ if (from.kind === "REPLENISHMENT_CREATED") {
+ if (to.kind === "DO_GROUP") return false;
+ if (to.kind !== "DO_OUT") return true;
+ const targetSolId = from.replenishmentStockOutLineId;
+ if (targetSolId != null && doOutNodeMatchesStockOutLineId(to.id, targetSolId)) {
+ return false;
+ }
+ const doCode = from.refCode?.trim();
+ if (doCode && doOutMatchesDeliveryCode(to, doCode)) return false;
+ return true;
+ }
+ return false;
+};
+
+const doOutNodeMatchesStockOutLineId = (nodeId: string, stockOutLineId: number): boolean =>
+ nodeId === `do-${stockOutLineId}` || nodeId.endsWith(`-${stockOutLineId}`);
+
+const doOutMatchesDeliveryCode = (doOut: TraceGraphLayoutNode, doCode: string): boolean => {
+ const code = doCode.trim();
+ if (!code) return false;
+ if (doOut.refCode?.trim() === code) return true;
+ if (doOut.title?.includes(code)) return true;
+ if (doOut.subtitle?.includes(code)) return true;
+ return (doOut.details ?? []).some((d) => d.value?.trim() === code);
+};
+
+const shouldSkipGenericPhaseEdge = shouldSkipTraceFlowEdge;
+
+const productionScopeKey = (n: TraceGraphLayoutNode): string =>
+ n.traceLotNo?.trim() || "__fg__";
+
+const normWh = (w?: string | null): string => (w ?? "").trim().toUpperCase();
+
+const normRefType = (refType?: string | null): string => (refType ?? "").trim().toUpperCase();
+
+const pickSourcePutawayForTransfer = (
+ putaways: TraceGraphLayoutNode[],
+ transfer: TraceGraphLayoutNode,
+): TraceGraphLayoutNode | null => {
+ const fromWh = normWh(transfer.transferFromWarehouse);
+ const candidates = putaways.filter((p) => p.sortKey <= transfer.sortKey);
+ const whMatched =
+ fromWh.length > 0
+ ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, fromWh))
+ : candidates;
+ if (!whMatched.length) return null;
+ const nonTransfer = whMatched.filter((p) => normRefType(p.refType) !== "TRANSFER");
+ const pool = nonTransfer.length > 0 ? nonTransfer : whMatched;
+ return [...pool].sort(sortNodesInPhase).at(-1) ?? null;
+};
+
+const pickDestPutawayForTransfer = (
+ putaways: TraceGraphLayoutNode[],
+ transfer: TraceGraphLayoutNode,
+): TraceGraphLayoutNode | null => {
+ const toWh = normWh(transfer.transferToWarehouse);
+ const candidates = putaways.filter((p) => p.sortKey >= transfer.sortKey);
+ const whMatched =
+ toWh.length > 0
+ ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, toWh))
+ : candidates;
+ if (!whMatched.length) return null;
+ const transferPutaways = whMatched.filter((p) => normRefType(p.refType) === "TRANSFER");
+ const pool = transferPutaways.length > 0 ? transferPutaways : whMatched;
+ return [...pool].sort(sortNodesInPhase)[0] ?? null;
+};
+
+const connectPutawayToWarehousePhase = (
+ fromList: TraceGraphLayoutNode[],
+ toList: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const transfers = toList.filter((n) => n.kind === "TRANSFER");
+ transfers.forEach((tr) => {
+ const src = pickSourcePutawayForTransfer(fromList, tr);
+ if (src) add(src, tr);
+ });
+
+ const transferInbounds = toList.filter(
+ (n) => n.kind === "PUTAWAY" && normRefType(n.refType) === "TRANSFER",
+ );
+ transferInbounds.forEach((dest) => {
+ const src = pickSourcePutawayForTransfer(fromList, dest);
+ if (src) add(src, dest);
+ });
+
+ if (transfers.length > 0 || transferInbounds.length > 0) {
+ const linked = new Set([
+ ...transfers.map((n) => n.id),
+ ...transferInbounds.map((n) => n.id),
+ ]);
+ const remaining = toList.filter((n) => !linked.has(n.id));
+ remaining.forEach((to) => {
+ // ADJUSTMENT / other warehouse events must link from same warehouse only.
+ const from = pickUpstreamForTarget(fromList, to);
+ if (from) add(from, to);
+ });
+ return;
+ }
+
+ toList.forEach((to) => {
+ const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!;
+ add(from, to);
+ });
+};
+
+const buildTransferPutawayEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const putaways = nodes.filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n));
+ const transfers = nodes.filter((n) => n.kind === "TRANSFER");
+ transfers.forEach((tr) => {
+ const src = pickSourcePutawayForTransfer(putaways, tr);
+ if (src) add(src, tr);
+ const dest = pickDestPutawayForTransfer(putaways, tr);
+ if (dest) add(tr, dest);
+ });
+
+ // Every 轉倉入庫 card needs an inbound edge (not only the first WAREHOUSE node).
+ const destInbound = putaways.filter((p) => normRefType(p.refType) === "TRANSFER");
+ destInbound.forEach((dest) => {
+ const src = pickSourcePutawayForTransfer(putaways, dest);
+ if (src && src.id !== dest.id) add(src, dest);
+ });
+};
+
+const PRELUDE_MATERIAL_PHASES: TraceGraphPhase[] = [
+ "PURCHASE",
+ "INBOUND",
+ "PRODUCTION",
+ "QC",
+ "PUTAWAY",
+ "MATERIAL_PICK",
+];
+
+const buildMaterialLotFlowEdgePairs = (
+ nodes: TraceGraphLayoutNode[],
+ phaseOrder: TraceGraphPhase[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ if (!phaseOrder.includes("MATERIAL_PICK")) return;
+
+ const preludePhases = PRELUDE_MATERIAL_PHASES.filter((p) => phaseOrder.includes(p));
+ const byLot = new Map();
+
+ nodes.forEach((n) => {
+ if (!isMaterialPreludeNode(n)) return;
+ const key = materialLotKey(n);
+ if (!key) return;
+ const list = byLot.get(key) ?? [];
+ list.push(n);
+ byLot.set(key, list);
+ });
+
+ const nodesInPhase = (lotNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) =>
+ lotNodes
+ .filter((n) => n.phase === phase && !NO_FLOW_EDGE_KINDS.has(n.kind))
+ .sort(sortNodesInPhase);
+
+ byLot.forEach((lotNodes) => {
+ const phasesPresent = sortPhasesByEarliestEvent(
+ lotNodes,
+ preludePhases.filter((p) => nodesInPhase(lotNodes, p).length > 0),
+ phaseOrder,
+ );
+
+ for (let i = 0; i < phasesPresent.length - 1; i++) {
+ const fromPhase = phasesPresent[i];
+ const toPhase = phasesPresent[i + 1];
+ const fromList = nodesInPhase(lotNodes, fromPhase);
+ const toList = nodesInPhase(lotNodes, toPhase);
+ if (!fromList.length || !toList.length) continue;
+
+ if (toPhase === "MATERIAL_PICK") {
+ const upstream = fromList[fromList.length - 1]!;
+ const seenPickTargets = new Set();
+ toList.forEach((pick) => {
+ const target = resolvePickFlowTarget(pick, nodes);
+ if (seenPickTargets.has(target.id)) return;
+ seenPickTargets.add(target.id);
+ add(upstream, target);
+ });
+ } else {
+ add(fromList[fromList.length - 1]!, toList[0]!);
+ }
+ }
+
+ // Same PUTAWAY lane: material 上架 → 庫存調整 (FG warehouse pattern).
+ const putaways = lotNodes
+ .filter((n) => n.kind === "PUTAWAY" && !NO_FLOW_EDGE_KINDS.has(n.kind))
+ .sort(sortNodesInPhase);
+ const adjustments = lotNodes
+ .filter((n) => n.kind === "ADJUSTMENT" && !NO_FLOW_EDGE_KINDS.has(n.kind))
+ .sort(sortNodesInPhase);
+ if (putaways.length && adjustments.length) {
+ adjustments.forEach((adj) => {
+ const targetWh = adj.warehouseCode?.trim();
+ const matched = targetWh
+ ? putaways.filter((p) => warehouseCodesMatch(p.warehouseCode, targetWh))
+ : putaways;
+ const from = (matched.length ? matched : putaways).at(-1);
+ if (from) add(from, adj);
+ });
+ }
+ });
+};
+
+const buildPutawayToOutboundEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const inventorySources = nodes
+ .filter(
+ (n) =>
+ !isMaterialPreludeNode(n) &&
+ (n.kind === "PUTAWAY" ||
+ n.kind === "TRANSFER" ||
+ n.kind === "ADJUSTMENT" ||
+ n.kind === "OPEN" ||
+ n.kind === "RECEIPT" ||
+ n.kind === "IN"),
+ )
+ .sort(sortNodesInPhase);
+ if (!inventorySources.length) return;
+
+ const outboundTargets = nodes.filter(
+ (n) =>
+ !isDoGroupChild(n) &&
+ (n.kind === "DO_OUT" ||
+ n.kind === "DO_GROUP" ||
+ n.kind === "JO_OUT" ||
+ n.kind === "PO_OUT" ||
+ n.kind === "OUT" ||
+ n.kind === "REPLENISHMENT_CREATED"),
+ );
+ if (!outboundTargets.length) return;
+
+ outboundTargets.forEach((target) => {
+ const from = pickUpstreamForTarget(inventorySources, target);
+ if (from && !shouldSkipGenericPhaseEdge(from, target)) add(from, target);
+ });
+};
+
+const buildPutawayToStockTakeEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const putaways = nodes
+ .filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n))
+ .sort(sortNodesInPhase);
+ if (!putaways.length) return;
+
+ nodes
+ .filter((n) => n.kind === "STOCK_TAKE" && !isDoGroupChild(n))
+ .forEach((stockTake) => {
+ const scope = nodeScopePrefix(stockTake);
+ // Stock take must stay within the same inventory-lot scope (loc-* / primary).
+ const scopedPutaways = putaways.filter((n) => nodeScopePrefix(n) === scope);
+ const from = pickUpstreamForTarget(scopedPutaways, stockTake);
+ if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake);
+ });
+};
+
+const inventoryWarehouseOf = (n: TraceGraphLayoutNode): string | undefined => {
+ if (n.kind === "TRANSFER") {
+ return n.transferToWarehouse?.trim() || n.warehouseCode?.trim();
+ }
+ return n.warehouseCode?.trim();
+};
+
+const pickUpstreamForTarget = (
+ fromList: TraceGraphLayoutNode[],
+ target: TraceGraphLayoutNode,
+): TraceGraphLayoutNode | null => {
+ if (!fromList.length) return null;
+
+ const byTime = fromList.filter(
+ (n) => n.column <= target.column || n.sortKey <= target.sortKey,
+ );
+ const pool = byTime.length > 0 ? byTime : fromList;
+
+ const targetWh = target.warehouseCode?.trim();
+ if (targetWh) {
+ const matched = pool.filter((n) =>
+ warehouseCodesMatch(inventoryWarehouseOf(n), targetWh),
+ );
+ if (matched.length) return matched[matched.length - 1]!;
+ // No warehouse match in time window — do not link across warehouses.
+ const anyMatched = fromList.filter((n) =>
+ warehouseCodesMatch(inventoryWarehouseOf(n), targetWh),
+ );
+ return anyMatched.length ? anyMatched[anyMatched.length - 1]! : null;
+ }
+
+ const scope = nodeScopePrefix(target);
+ const scoped = pool.filter((n) => nodeScopePrefix(n) === scope);
+ if (scoped.length) return scoped[scoped.length - 1]!;
+
+ const upstreamWhs = new Set(
+ pool
+ .map((n) => (inventoryWarehouseOf(n) ?? "").trim().toUpperCase())
+ .filter(Boolean),
+ );
+ if (upstreamWhs.size <= 1) return pool[pool.length - 1]!;
+ return null;
+};
+
+const buildFgLotFlowEdgePairs = (
+ nodes: TraceGraphLayoutNode[],
+ phaseOrder: TraceGraphPhase[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const fgPhases = phaseOrder.filter((p) => p !== "MATERIAL_PICK");
+ const fgNodes = nodes.filter(
+ (n) =>
+ !isMaterialPreludeNode(n) &&
+ !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
+ !NO_FLOW_EDGE_KINDS.has(n.kind) &&
+ !isDoGroupChild(n),
+ );
+ if (fgNodes.length < 2) return;
+
+ const nodesInPhase = (phase: TraceGraphPhase) =>
+ fgNodes.filter((n) => n.phase === phase).sort(sortNodesInPhase);
+
+ const phasesPresent = sortFgPhasesForCrossDayFlow(
+ fgNodes,
+ fgPhases.filter((p) => nodesInPhase(p).length > 0),
+ phaseOrder,
+ );
+
+ for (let i = 0; i < phasesPresent.length - 1; i++) {
+ const fromPhase = phasesPresent[i];
+ const toPhase = phasesPresent[i + 1];
+ const fromList = nodesInPhase(fromPhase);
+ const toList = nodesInPhase(toPhase);
+ if (!fromList.length || !toList.length) continue;
+
+ if (fromPhase === "OUTBOUND" && toPhase === "STOCK_TAKE") continue;
+
+ if (
+ fromPhase === "OUTBOUND" &&
+ (toPhase === "WAREHOUSE" || toPhase === "PUTAWAY" || toPhase === "STOCK_TAKE")
+ ) {
+ continue;
+ }
+
+ if (fromPhase === "PUTAWAY" && toPhase === "WAREHOUSE") {
+ connectPutawayToWarehousePhase(fromList, toList, add);
+ continue;
+ }
+
+ if (toPhase === "OUTBOUND") {
+ toList.forEach((out) => {
+ const scope = nodeScopePrefix(out);
+ const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
+ const from = pickUpstreamForTarget(scopedFrom, out);
+ if (from && !shouldSkipGenericPhaseEdge(from, out)) add(from, out);
+ });
+ } else if (toPhase === "STOCK_TAKE") {
+ toList.forEach((stockTake) => {
+ const scope = nodeScopePrefix(stockTake);
+ const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
+ const from = pickUpstreamForTarget(scopedFrom, stockTake);
+ if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake);
+ });
+ } else if (toList.length > 1) {
+ const targets =
+ toPhase === "PUTAWAY"
+ ? (() => {
+ const nonTransfer = toList.filter((n) => normRefType(n.refType) !== "TRANSFER");
+ return nonTransfer.length > 0 ? nonTransfer : toList;
+ })()
+ : toList;
+ targets.forEach((to) => {
+ const from = pickUpstreamForTarget(fromList, to);
+ if (from && !shouldSkipGenericPhaseEdge(from, to)) add(from, to);
+ });
+ } else {
+ const to = toList[0]!;
+ const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!;
+ if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to);
+ }
+ }
+};
+
+const sortProductionStepsForChain = (
+ a: TraceGraphLayoutNode,
+ b: TraceGraphLayoutNode,
+): number => {
+ const sa = a.bomProcessSeqNo;
+ const sb = b.bomProcessSeqNo;
+ if (sa != null && sb != null && sa !== sb) return sa - sb;
+ if (sa != null && sb == null) return -1;
+ if (sa == null && sb != null) return 1;
+ return sortNodesInPhase(a, b);
+};
+
+const buildMaterialPickToProductionStepEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const prodSteps = nodes.filter(
+ (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n),
+ );
+ if (!prodSteps.length) return;
+
+ const productionByScopeAndProcess = new Map();
+ prodSteps.forEach((n) => {
+ if (n.bomProcessId == null) return;
+ const key = `${productionScopeKey(n)}::${n.bomProcessId}`;
+ const list = productionByScopeAndProcess.get(key) ?? [];
+ list.push(n);
+ productionByScopeAndProcess.set(key, list);
+ });
+
+ const resolveProductionTarget = (
+ scope: string,
+ pick: TraceGraphLayoutNode,
+ ): TraceGraphLayoutNode | null => {
+ const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope;
+
+ if (pick.bomProcessId != null) {
+ const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`);
+ if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!;
+ }
+
+ if (pick.bomProcessSeqNo != null) {
+ const bySeq = prodSteps.filter(
+ (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo,
+ );
+ if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!;
+ }
+
+ const itemCode = pick.traceItemCode?.trim().toUpperCase();
+ if (itemCode) {
+ const byItem = prodSteps.filter(
+ (n) =>
+ inScope(n) &&
+ (n.stepMaterialItemCodes ?? []).some(
+ (code) => code.trim().toUpperCase() === itemCode,
+ ),
+ );
+ if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!;
+ }
+
+ return null;
+ };
+
+ const connectPickOrGroup = (
+ source: TraceGraphLayoutNode,
+ pick: TraceGraphLayoutNode,
+ seenTargets: Set,
+ ) => {
+ const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__";
+ const target = resolveProductionTarget(scope, pick);
+ if (!target || seenTargets.has(target.id)) return;
+ seenTargets.add(target.id);
+ add(
+ {
+ ...source,
+ feedsProductionScopeLotNo: pick.feedsProductionScopeLotNo ?? source.feedsProductionScopeLotNo,
+ },
+ target,
+ );
+ };
+
+ nodes
+ .filter((n) => n.kind === "PICK_GROUP")
+ .forEach((group) => {
+ const children = nodes.filter(
+ (n) => n.doGroupId === group.id && n.kind === "MATERIAL_PICK",
+ );
+ const seenTargets = new Set();
+ children.forEach((child) => connectPickOrGroup(group, child, seenTargets));
+ });
+
+ nodes
+ .filter((n) => n.kind === "MATERIAL_PICK" && !n.doGroupId)
+ .forEach((pick) => connectPickOrGroup(pick, pick, new Set()));
+};
+
+const buildProductionToFgQcEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const prodSteps = nodes.filter(
+ (n) => n.kind === "PRODUCTION_STEP" && !isMaterialPreludeNode(n) && !isDoGroupChild(n),
+ );
+ const qcNodes = nodes.filter((n) => n.kind === "QC" && !isMaterialPreludeNode(n));
+ if (!prodSteps.length || !qcNodes.length) return;
+
+ const lastProd = [...prodSteps].sort(sortNodesInPhase).at(-1)!;
+ const firstQc = [...qcNodes].sort(sortNodesInPhase)[0]!;
+ if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc);
+};
+
+const buildProductionStepChainEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const byScope = new Map();
+ nodes
+ .filter((n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n))
+ .forEach((n) => {
+ const key = productionScopeKey(n);
+ const list = byScope.get(key) ?? [];
+ list.push(n);
+ byScope.set(key, list);
+ });
+
+ byScope.forEach((list) => {
+ const sorted = [...list].sort(sortProductionStepsForChain);
+ for (let i = 0; i < sorted.length - 1; i++) {
+ add(sorted[i]!, sorted[i + 1]!);
+ }
+ });
+};
+
+const joCodeForPickTarget = (
+ target: TraceGraphLayoutNode,
+ nodes: TraceGraphLayoutNode[],
+): string | null => {
+ // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*.
+ const fromNode = (n: TraceGraphLayoutNode): string | null => {
+ const jo = n.jobOrderCode?.trim();
+ if (jo) return jo;
+ const conso = n.consoCode?.trim();
+ if (conso && /^JO[-_]/i.test(conso)) return conso;
+ return null;
+ };
+
+ if (target.kind === "PICK_GROUP") {
+ const direct = fromNode(target);
+ if (direct) return direct;
+ const child = nodes.find((n) => n.doGroupId === target.id && fromNode(n));
+ return child ? fromNode(child) : null;
+ }
+ if (target.kind === "MATERIAL_PICK") {
+ return fromNode(target);
+ }
+ return null;
+};
+
+const buildJoCreatedToMaterialPickEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED");
+ if (!joCreatedNodes.length) return;
+
+ const pickTargets = nodes.filter(
+ (n) =>
+ !isDoGroupChild(n) &&
+ (n.kind === "PICK_GROUP" ||
+ (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())),
+ );
+ if (!pickTargets.length) return;
+
+ const feedLotForTarget = (target: TraceGraphLayoutNode): string => {
+ if (target.kind === "PICK_GROUP") {
+ return (
+ nodes
+ .find(
+ (n) =>
+ n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()),
+ )
+ ?.feedsProductionScopeLotNo?.trim() ?? ""
+ );
+ }
+ return target.feedsProductionScopeLotNo?.trim() ?? "";
+ };
+
+ pickTargets.forEach((target) => {
+ const pickJoCode = joCodeForPickTarget(target, nodes);
+ const matches = joCreatedNodes.filter((jo) => {
+ const joCode = jo.refCode?.trim();
+ return Boolean(joCode && pickJoCode && joCode === pickJoCode);
+ });
+ if (!matches.length) return;
+
+ const feedLot = feedLotForTarget(target);
+ let pool: TraceGraphLayoutNode[];
+ if (feedLot) {
+ const scoped = matches.filter((jo) => jo.traceLotNo?.trim() === feedLot);
+ pool = scoped.length ? scoped : matches;
+ } else {
+ // FG 工單提料: prefer the FG 建立工單 card (no material lot), not nested JO output.
+ const fg = matches.filter((jo) => !jo.traceLotNo?.trim());
+ pool = fg.length ? fg : matches;
+ }
+ const joCreated = [...pool].sort(sortNodesInPhase)[0]!;
+ add(joCreated, target);
+ });
+};
+
+const resolveDoOutFlowTarget = (
+ doOut: TraceGraphLayoutNode,
+ nodes: TraceGraphLayoutNode[],
+): TraceGraphLayoutNode => {
+ const groupId = doOut.doGroupId?.trim();
+ if (!groupId) return doOut;
+ return nodes.find((n) => n.id === groupId) ?? doOut;
+};
+
+const findDoOutForReplenishment = (
+ replenishment: TraceGraphLayoutNode,
+ nodes: TraceGraphLayoutNode[],
+): TraceGraphLayoutNode | null => {
+ const stockOutLineId = replenishment.replenishmentStockOutLineId;
+ const doOuts = nodes.filter((n) => n.kind === "DO_OUT");
+ if (stockOutLineId != null) {
+ const bySol = doOuts.find((n) => doOutNodeMatchesStockOutLineId(n.id, stockOutLineId));
+ if (bySol) return bySol;
+ }
+ const doCode = replenishment.refCode?.trim();
+ if (!doCode) return null;
+ const byCode = doOuts.filter((n) => doOutMatchesDeliveryCode(n, doCode));
+ if (!byCode.length) return null;
+ const replenishFlagged = byCode.filter((n) => n.doOutboundIsReplenish);
+ const pool = replenishFlagged.length ? replenishFlagged : byCode;
+ return [...pool].sort(sortNodesInPhase)[0] ?? null;
+};
+
+const buildReplenishmentToDoOutEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ nodes
+ .filter((n) => n.kind === "REPLENISHMENT_CREATED")
+ .forEach((replenishment) => {
+ const doOut = findDoOutForReplenishment(replenishment, nodes);
+ if (!doOut) return;
+ add(replenishment, resolveDoOutFlowTarget(doOut, nodes));
+ });
+};
+
+const connectWarehousePhaseChainEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const chainNodes = nodes
+ .filter(
+ (n) =>
+ !isMaterialPreludeNode(n) &&
+ !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
+ !NO_FLOW_EDGE_KINDS.has(n.kind) &&
+ !isDoGroupChild(n),
+ )
+ .sort(sortNodesInPhase);
+
+ chainNodes
+ .filter((n) => WAREHOUSE_FLOW_KINDS.has(n.kind))
+ .forEach((warehouseNode) => {
+ if (warehouseNode.kind === "TRANSFER") {
+ const putaways = chainNodes.filter(
+ (n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n),
+ );
+ const src = pickSourcePutawayForTransfer(putaways, warehouseNode);
+ if (src) {
+ add(src, warehouseNode);
+ return;
+ }
+ }
+
+ const predecessors = chainNodes.filter((n) => n.sortKey < warehouseNode.sortKey);
+ const targetWh = warehouseNode.warehouseCode?.trim();
+ const sameWarehouse = (n: TraceGraphLayoutNode) =>
+ !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh);
+
+ const warehousePredecessors = predecessors.filter(
+ (n) => WAREHOUSE_FLOW_KINDS.has(n.kind) && sameWarehouse(n),
+ );
+ if (warehousePredecessors.length > 0) {
+ add(warehousePredecessors[warehousePredecessors.length - 1]!, warehouseNode);
+ return;
+ }
+
+ const inventoryPredecessors = predecessors.filter(
+ (n) => INVENTORY_STATE_KINDS.has(n.kind) && sameWarehouse(n),
+ );
+ if (inventoryPredecessors.length > 0) {
+ add(inventoryPredecessors[inventoryPredecessors.length - 1]!, warehouseNode);
+ return;
+ }
+
+ // ADJUSTMENT with a known warehouse must not fall back to a different warehouse.
+ if (warehouseNode.kind === "ADJUSTMENT" && targetWh) return;
+
+ const lastBefore = predecessors[predecessors.length - 1];
+ if (lastBefore) add(lastBefore, warehouseNode);
+ });
+};
+
+const connectStockTakeInboundEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const chainNodes = nodes
+ .filter(
+ (n) =>
+ !isMaterialPreludeNode(n) &&
+ !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
+ !NO_FLOW_EDGE_KINDS.has(n.kind) &&
+ !isDoGroupChild(n),
+ )
+ .sort(sortNodesInPhase);
+
+ const inventoryPredecessorKinds = new Set([
+ "PUTAWAY",
+ "TRANSFER",
+ "ADJUSTMENT",
+ "REPACK",
+ "RECEIPT",
+ "IN",
+ "OPEN",
+ ]);
+
+ chainNodes
+ .filter((n) => n.kind === "STOCK_TAKE")
+ .forEach((stockTake) => {
+ const targetScope = nodeScopePrefix(stockTake);
+ const targetWh = stockTake.warehouseCode?.trim();
+ const targetLot = stockTake.traceLotNo?.trim();
+ const sameLot = (n: TraceGraphLayoutNode) => {
+ if (nodeScopePrefix(n) !== targetScope) return false;
+ if (!targetLot) return true;
+ const fromLot = n.traceLotNo?.trim();
+ return !fromLot || fromLot === targetLot;
+ };
+ const sameWarehouse = (n: TraceGraphLayoutNode) =>
+ !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh);
+
+ const predecessors = chainNodes.filter(
+ (n) =>
+ n.kind !== "STOCK_TAKE" &&
+ n.sortKey < stockTake.sortKey &&
+ sameLot(n),
+ );
+ if (!predecessors.length) return;
+
+ const putawayPredecessors = predecessors.filter(
+ (n) =>
+ n.kind === "PUTAWAY" && !isMaterialPreludeNode(n) && sameWarehouse(n),
+ );
+ const inventoryPredecessors = predecessors.filter(
+ (n) => inventoryPredecessorKinds.has(n.kind) && sameWarehouse(n),
+ );
+ const from =
+ putawayPredecessors[putawayPredecessors.length - 1] ??
+ inventoryPredecessors[inventoryPredecessors.length - 1] ??
+ // Known warehouse: never fall back across warehouses / other lots.
+ (targetWh ? undefined : predecessors[predecessors.length - 1]);
+ if (from) add(from, stockTake);
+ });
+};
+
+export const buildTraceFlowEdgePairs = (
+ nodes: TraceGraphLayoutNode[],
+ phaseOrder: TraceGraphPhase[],
+): Array<{ fromId: string; toId: string }> => {
+ const pairs: Array<{ fromId: string; toId: string }> = [];
+ const seen = new Set();
+
+ const add = (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => {
+ if (NO_FLOW_EDGE_SOURCE_KINDS.has(from.kind) || NO_FLOW_EDGE_KINDS.has(from.kind)) return;
+ if (NO_FLOW_EDGE_KINDS.has(to.kind)) return;
+ const key = `${from.id}->${to.id}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ pairs.push({ fromId: from.id, toId: to.id });
+ };
+
+ const byDay = new Map();
+ nodes.forEach((n) => {
+ const list = byDay.get(n.dayKey) ?? [];
+ list.push(n);
+ byDay.set(n.dayKey, list);
+ });
+
+ const sortedDays = Array.from(byDay.keys()).sort((a, b) => {
+ if (a === "—") return 1;
+ if (b === "—") return -1;
+ return a.localeCompare(b);
+ });
+
+ const nodesInPhase = (dayNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) =>
+ dayNodes
+ .filter(
+ (n) =>
+ n.phase === phase &&
+ !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
+ !NO_FLOW_EDGE_KINDS.has(n.kind) &&
+ !isDoGroupChild(n),
+ )
+ .sort(sortNodesInPhase);
+
+ for (const dayKey of sortedDays) {
+ const dayNodes = byDay.get(dayKey)!;
+ const phasesPresent = sortPhasesByEarliestEvent(
+ dayNodes,
+ phaseOrder.filter((p) => nodesInPhase(dayNodes, p).length > 0),
+ phaseOrder,
+ dayKey,
+ );
+
+ for (let i = 0; i < phasesPresent.length - 1; i++) {
+ const fromList = nodesInPhase(dayNodes, phasesPresent[i]);
+ const toList = nodesInPhase(dayNodes, phasesPresent[i + 1]);
+ if (!fromList.length || !toList.length) continue;
+ const from = fromList[fromList.length - 1]!;
+ const to = toList[0]!;
+ if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue;
+
+ if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") {
+ continue;
+ }
+
+ if (phasesPresent[i] === "PUTAWAY" && phasesPresent[i + 1] === "WAREHOUSE") {
+ connectPutawayToWarehousePhase(fromList, toList, add);
+ continue;
+ }
+
+ if (phasesPresent[i + 1] === "OUTBOUND" || phasesPresent[i + 1] === "STOCK_TAKE") {
+ toList.forEach((target) => {
+ const scope = nodeScopePrefix(target);
+ const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
+ const matched = pickUpstreamForTarget(scopedFrom, target);
+ if (matched && !shouldSkipGenericPhaseEdge(matched, target)) add(matched, target);
+ });
+ continue;
+ }
+
+ if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to);
+ }
+ }
+
+ buildMaterialLotFlowEdgePairs(nodes, phaseOrder, add);
+ buildProductionStepChainEdges(nodes, add);
+ buildProductionToFgQcEdges(nodes, add);
+ buildJoCreatedToMaterialPickEdges(nodes, add);
+ buildReplenishmentToDoOutEdges(nodes, add);
+ buildMaterialPickToProductionStepEdges(nodes, add);
+ buildFgLotFlowEdgePairs(nodes, phaseOrder, add);
+ buildPutawayToOutboundEdges(nodes, add);
+ buildPutawayToStockTakeEdges(nodes, add);
+ buildTransferPutawayEdges(nodes, add);
+ connectWarehousePhaseChainEdges(nodes, add);
+ connectStockTakeInboundEdges(nodes, add);
+ buildTerminalStateEdges(nodes, add);
+
+ return pairs;
+};
+
+export type TraceFlowEdgePair = { fromId: string; toId: string };
+
+/** Build edges from backend traceGraph when present; merge with local semantics. */
+export const resolveTraceFlowEdgePairs = (
+ nodes: TraceGraphLayoutNode[],
+ phaseOrder: TraceGraphPhase[],
+ backendEdges?: Array<{ fromKey: string; toKey: string }> | null,
+): TraceFlowEdgePair[] => {
+ const local = buildTraceFlowEdgePairs(nodes, phaseOrder);
+ if (!backendEdges?.length) return local;
+
+ const nodeByKey = new Map(nodes.map((n) => [n.id, n]));
+ const merged = new Map();
+ const addPair = (fromId: string, toId: string) => {
+ const from = nodeByKey.get(fromId);
+ const to = nodeByKey.get(toId);
+ if (!from || !to) return;
+ if (shouldSkipTraceFlowEdge(from, to)) return;
+ merged.set(`${fromId}->${toId}`, { fromId, toId });
+ };
+
+ backendEdges.forEach((e) => addPair(e.fromKey, e.toKey));
+ local.forEach((e) => addPair(e.fromId, e.toId));
+ return Array.from(merged.values());
+};
diff --git a/src/components/ItemTracing/traceLabelUtils.ts b/src/components/ItemTracing/traceLabelUtils.ts
new file mode 100644
index 0000000..af0408c
--- /dev/null
+++ b/src/components/ItemTracing/traceLabelUtils.ts
@@ -0,0 +1,192 @@
+import { TFunction } from "i18next";
+import { ItemLotTraceQcResult } from "@/app/api/itemTracing";
+
+const normCode = (code: string | null | undefined) => (code ?? "").trim();
+
+const lookupCode = (t: TFunction, prefix: string, code: string | null | undefined): string => {
+ const raw = normCode(code);
+ if (!raw) return "—";
+ const variants = [raw, raw.toUpperCase(), raw.toLowerCase()];
+ for (const v of variants) {
+ const key = `${prefix}.${v}`;
+ const translated = t(key, { defaultValue: "" });
+ if (translated && translated !== key) return translated;
+ }
+ return raw.replace(/_/g, " ");
+};
+
+export type TraceLabelTranslator = {
+ refType: (code: string | null | undefined) => string;
+ movementType: (code: string | null | undefined) => string;
+ direction: (code: string | null | undefined) => string;
+ stockInStatus: (code: string | null | undefined) => string;
+ adjustmentType: (code: string | null | undefined) => string;
+ usageType: (code: string | null | undefined) => string;
+ failType: (code: string | null | undefined) => string;
+ productionStatus: (code: string | null | undefined) => string;
+ pickStatus: (code: string | null | undefined) => string;
+ processingStatus: (code: string | null | undefined) => string;
+ matchStatus: (code: string | null | undefined) => string;
+ lotLineStatus: (code: string | null | undefined) => string;
+ joStatus: (code: string | null | undefined) => string;
+ qcType: (code: string | null | undefined) => string;
+ qcPassed: (passed: boolean) => string;
+ putawayShelfStatus: (phase: "pending" | "completed") => string;
+};
+
+export const createTraceLabelTranslator = (t: TFunction): TraceLabelTranslator => ({
+ refType: (code) => lookupCode(t, "code.refType", code),
+ movementType: (code) => lookupCode(t, "code.movementType", code),
+ direction: (code) => {
+ const u = normCode(code).toUpperCase();
+ if (u === "IN") return t("directionIn");
+ if (u === "OUT") return t("directionOut");
+ return lookupCode(t, "code.direction", code);
+ },
+ stockInStatus: (code) => lookupCode(t, "code.stockInStatus", code),
+ adjustmentType: (code) => lookupCode(t, "code.adjustmentType", code),
+ usageType: (code) => lookupCode(t, "code.usageType", code),
+ failType: (code) => lookupCode(t, "code.failType", code),
+ productionStatus: (code) => lookupCode(t, "code.productionStatus", code),
+ pickStatus: (code) => lookupCode(t, "code.pickStatus", code),
+ processingStatus: (code) => lookupCode(t, "code.processingStatus", code),
+ matchStatus: (code) => lookupCode(t, "code.matchStatus", code),
+ lotLineStatus: (code) => lookupCode(t, "code.lotLineStatus", code),
+ joStatus: (code) => lookupCode(t, "code.joStatus", code),
+ qcType: (code) => {
+ const raw = normCode(code);
+ const u = raw.toUpperCase();
+ if (!raw || u === "QC") return t("code.qcType.IQC");
+ return lookupCode(t, "code.qcType", code);
+ },
+ qcPassed: (passed) => (passed ? t("qcPassed") : t("qcFailed")),
+ putawayShelfStatus: (phase) =>
+ phase === "completed" ? t("putawayStatusCompleted") : t("putawayStatusPending"),
+});
+
+/** Color for 處理狀態 / 對料狀態 values on flow cards and detail panel. */
+export type PickStatusValueColor = "success" | "error" | "warning" | "info" | "default";
+
+export const pickStatusValueColor = (
+ code: string | null | undefined,
+): PickStatusValueColor => {
+ switch (normCode(code).toLowerCase()) {
+ case "completed":
+ return "success";
+ case "rejected":
+ return "error";
+ case "scanned":
+ return "info";
+ case "pending":
+ case "created":
+ return "warning";
+ default:
+ return "default";
+ }
+};
+
+export const qcItemShortLabel = (q: ItemLotTraceQcResult): string =>
+ q.qcItemName?.trim() || q.qcItemCode?.trim() || "";
+
+export const qcItemLabel = (q: ItemLotTraceQcResult): string => {
+ const name = qcItemShortLabel(q);
+ const desc = q.qcItemDescription?.trim();
+ if (name && desc) return `${name}(${desc})`;
+ return name || desc || "";
+};
+
+export const groupQcResultsBySession = (
+ results: ItemLotTraceQcResult[],
+): ItemLotTraceQcResult[][] => {
+ const map = new Map();
+ results.forEach((q) => {
+ const key = `${q.stockInLineId}:${q.created ?? ""}`;
+ const list = map.get(key) ?? [];
+ list.push(q);
+ map.set(key, list);
+ });
+ return Array.from(map.values());
+};
+
+export const buildQcCriteriaLines = (
+ results: ItemLotTraceQcResult[],
+ tr: TraceLabelTranslator,
+ unknownItemLabel: string,
+ failQtyLabel: string,
+): string => {
+ if (results.length === 0) return "—";
+ return results
+ .map((q) => {
+ const item = qcItemLabel(q) || unknownItemLabel;
+ const status = q.qcPassed
+ ? tr.qcPassed(true)
+ : `${tr.qcPassed(false)}${q.failQty > 0 ? `(${failQtyLabel}: ${q.failQty})` : ""}`;
+ return `${item}:${status}`;
+ })
+ .join("\n");
+};
+
+export const buildQcSubtitle = (
+ results: ItemLotTraceQcResult[],
+ formatQcSubtitle: (failQty: number, acceptedQty: number) => string,
+ extra?: string,
+): string => {
+ const first = results[0];
+ const qtyLine = first ? formatQcSubtitle(first.failQty, first.acceptedQty) : "";
+ const criteria = results
+ .map((q) => qcItemShortLabel(q))
+ .filter(Boolean)
+ .slice(0, 3)
+ .join(" · ");
+ const parts = [extra, criteria, qtyLine].filter(Boolean);
+ return parts.join(" · ") || qtyLine || "—";
+};
+
+export type DoOutboundKindLabels = {
+ extra: string;
+ replenish: string;
+};
+
+/** Detail label for 加單·補貨. */
+export const formatDoOutboundKindLabel = (
+ isExtra?: boolean,
+ isReplenish?: boolean,
+ labels?: DoOutboundKindLabels,
+): string | undefined => {
+ if (!labels) return undefined;
+ const parts: string[] = [];
+ if (isExtra) parts.push(labels.extra);
+ if (isReplenish) parts.push(labels.replenish);
+ return parts.length > 0 ? parts.join(" · ") : undefined;
+};
+
+import {
+ resolveTraceDoOutboundIsExtra,
+ type TraceDoOutboundExtraSource,
+} from "@/utils/traceDoOutboundExtra";
+
+export type DoOutboundChipSource = TraceDoOutboundExtraSource & {
+ isReplenish?: boolean;
+};
+
+/** Resolve chip flags from API fields, with ticket-type fallback (TI-E / TI-M rules). */
+export const resolveDoOutboundChipFlags = (
+ source: DoOutboundChipSource,
+): { isExtra: boolean; isReplenish: boolean } => ({
+ isExtra: resolveTraceDoOutboundIsExtra(source),
+ isReplenish: source.isReplenish === true,
+});
+
+/**
+ * FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20
+ * Outbound qty color (DO_OUT / JO_OUT / MATERIAL_PICK):
+ * 改數 → orange; else qty > 0 → green; qty ≤ 0 → red.
+ */
+export const resolveDoOutboundQtyColor = (
+ qty: number | null | undefined,
+ qtyChanged?: boolean,
+): "warning.main" | "success.main" | "error.main" | undefined => {
+ if (qtyChanged) return "warning.main";
+ if (qty == null || !Number.isFinite(Number(qty))) return undefined;
+ return Number(qty) > 0 ? "success.main" : "error.main";
+};
diff --git a/src/components/ItemTracing/traceNavigationUtils.ts b/src/components/ItemTracing/traceNavigationUtils.ts
new file mode 100644
index 0000000..238e39a
--- /dev/null
+++ b/src/components/ItemTracing/traceNavigationUtils.ts
@@ -0,0 +1,14 @@
+export type TraceNavigateParams = {
+ stockInLineId?: number;
+ lotNo?: string;
+ itemCode?: string;
+};
+
+export const buildItemTracingHref = (params: TraceNavigateParams): string => {
+ const q = new URLSearchParams();
+ if (params.stockInLineId != null) q.set("stockInLineId", String(params.stockInLineId));
+ if (params.lotNo?.trim()) q.set("lotNo", params.lotNo.trim());
+ if (params.itemCode?.trim()) q.set("itemCode", params.itemCode.trim());
+ const qs = q.toString();
+ return qs ? `/itemTracing?${qs}` : "/itemTracing";
+};
diff --git a/src/components/ItemTracing/traceNodeFactory.ts b/src/components/ItemTracing/traceNodeFactory.ts
new file mode 100644
index 0000000..2446c94
--- /dev/null
+++ b/src/components/ItemTracing/traceNodeFactory.ts
@@ -0,0 +1,213 @@
+import dayjs from "dayjs";
+import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
+import {
+ TraceGraphDetailField,
+ TraceGraphDocLinkKind,
+ TraceGraphNode,
+} from "./buildTraceGraphNodes";
+import { formatQty } from "./traceQtyUtils";
+import { pickStatusValueColor } from "./traceLabelUtils";
+import { normalizeTargetDateForLink } from "./traceDocLinkUtils";
+
+export type MaterialPickLabels = {
+ nodeMaterialPick: string;
+ categoryPick: string;
+ pickOrder: string;
+ jobOrder: string;
+ detailMaterial: string;
+ detailLot: string;
+ detailQty: string;
+ detailType: string;
+ detailAssignedStep: string;
+ detailPickTargetDate: string;
+ detailTime: string;
+ processingStatus: string;
+ matchStatus: string;
+ tr: {
+ processingStatus: (code: string | null | undefined) => string;
+ matchStatus: (code: string | null | undefined) => string;
+ };
+};
+
+export const pickStatusDetailFields = (
+ labels: Pick,
+ processingStatus?: string | null,
+ matchStatus?: string | null,
+ opts?: { always?: boolean },
+): TraceGraphDetailField[] => {
+ const always = opts?.always === true;
+ const rows: TraceGraphDetailField[] = [];
+ if (always || processingStatus?.trim()) {
+ rows.push(
+ field(labels.processingStatus, labels.tr.processingStatus(processingStatus), {
+ valueColor: pickStatusValueColor(processingStatus),
+ }),
+ );
+ }
+ if (always || matchStatus?.trim()) {
+ rows.push(
+ field(labels.matchStatus, labels.tr.matchStatus(matchStatus), {
+ valueColor: pickStatusValueColor(matchStatus),
+ }),
+ );
+ }
+ return rows;
+};
+
+export const pickStatusNodeLabels = (
+ labels: Pick,
+ processingStatus?: string | null,
+ matchStatus?: string | null,
+ opts?: { always?: boolean },
+): Pick<
+ TraceGraphNode,
+ "processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus"
+> => {
+ const always = opts?.always === true;
+ const showProcessing = always || Boolean(processingStatus?.trim());
+ const showMatch = always || Boolean(matchStatus?.trim());
+ return {
+ processingStatusLabel: showProcessing
+ ? labels.tr.processingStatus(processingStatus)
+ : undefined,
+ matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined,
+ processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined,
+ matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined,
+ };
+};
+
+export const fmt = (v: string | null | undefined) => (v?.trim() ? v : "—");
+
+export const field = (
+ label: string,
+ value: string | null | undefined,
+ extra?: Partial,
+): TraceGraphDetailField => ({
+ label,
+ value: fmt(value),
+ ...extra,
+});
+
+/** Omit detail row when value is blank (used for optional remarks, etc.). */
+export const fieldIf = (
+ label: string,
+ value: string | number | null | undefined,
+ extra?: Partial,
+): TraceGraphDetailField | null => {
+ const raw = value == null ? "" : String(value).trim();
+ if (!raw) return null;
+ return field(label, raw, extra);
+};
+
+export const detailsOf = (
+ ...rows: Array