Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Generated
+544
-5
@@ -11,6 +11,8 @@
|
||||
"@fontsource/barlow-condensed": "5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "5.3.0",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.18.4"
|
||||
@@ -21,6 +23,7 @@
|
||||
"@testing-library/jest-dom": "6.10.0",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/react": "18.3.31",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
@@ -1212,6 +1215,271 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.11.tgz",
|
||||
"integrity": "sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.11",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.11",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.11",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.11",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.11",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.11",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.11",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.11",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.11",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.11",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.11.tgz",
|
||||
"integrity": "sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.11.tgz",
|
||||
"integrity": "sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.11.tgz",
|
||||
"integrity": "sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.11.tgz",
|
||||
"integrity": "sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.11.tgz",
|
||||
"integrity": "sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.11.tgz",
|
||||
"integrity": "sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.11.tgz",
|
||||
"integrity": "sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.11.tgz",
|
||||
"integrity": "sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.11.tgz",
|
||||
"integrity": "sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.11.tgz",
|
||||
"integrity": "sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.11.tgz",
|
||||
"integrity": "sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -1815,6 +2083,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
@@ -2319,7 +2597,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2329,7 +2606,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -2443,6 +2719,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001816",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz",
|
||||
@@ -2491,11 +2776,21 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -2508,7 +2803,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
@@ -2613,6 +2907,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -2637,6 +2940,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
@@ -2651,6 +2960,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -3049,6 +3364,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -3196,6 +3520,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -3565,6 +3898,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -3595,7 +3937,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3618,6 +3959,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.3.289",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
|
||||
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3638,6 +3991,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.30",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
||||
@@ -3715,6 +4077,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -3793,6 +4172,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -3895,6 +4289,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -3955,6 +4355,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
@@ -4445,6 +4871,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"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",
|
||||
@@ -4472,6 +4904,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
@@ -4511,6 +4957,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -4518,6 +4970,93 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"@fontsource/barlow-condensed": "5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "5.3.0",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.18.4"
|
||||
@@ -25,6 +27,7 @@
|
||||
"@testing-library/jest-dom": "6.10.0",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/react": "18.3.31",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("routes", () => {
|
||||
mockFetch({ "/api/auth/me": { body: makeMe() } });
|
||||
renderApp("/admin");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
expect(screen.getByText("This screen arrives in phase 2.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Projects and job packets")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+21
-1
@@ -4,10 +4,13 @@ import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { AboutPage } from "./pages/AboutPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { PacketPage } from "./packet/PacketPage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
import { SignInPage } from "./pages/SignInPage";
|
||||
import { AccountPage } from "./screens/account/AccountPage";
|
||||
import { ScreenRoute } from "./screens/ScreenRoute";
|
||||
import { ProjectPage } from "./screens/clerical/ProjectPage";
|
||||
import { UnitPage } from "./screens/clerical/UnitPage";
|
||||
import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute";
|
||||
|
||||
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
|
||||
export function AppRoutes() {
|
||||
@@ -20,6 +23,23 @@ export function AppRoutes() {
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="account" element={<AccountPage />} />
|
||||
<Route path=":screen" element={<ScreenRoute />} />
|
||||
<Route
|
||||
path="clerical/projects/:projectId"
|
||||
element={
|
||||
<RequireScreen screen="clerical">
|
||||
<ProjectPage />
|
||||
</RequireScreen>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="clerical/units/:unitId"
|
||||
element={
|
||||
<RequireScreen screen="clerical">
|
||||
<UnitPage />
|
||||
</RequireScreen>
|
||||
}
|
||||
/>
|
||||
<Route path="packets/:id" element={<PacketPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Asset upload and the blob URL. The upload is multipart through the shared request.
|
||||
import { api, request } from "./client";
|
||||
import type { Asset } from "./types";
|
||||
|
||||
/** Same-origin URL of a stored file. */
|
||||
export function blobUrl(assetId: string, download = false): string {
|
||||
return `/_blob/${encodeURIComponent(assetId)}${download ? "?download=1" : ""}`;
|
||||
}
|
||||
|
||||
export const assetsApi = {
|
||||
upload: (file: File, opts: { packet_id?: number; purpose?: string } = {}) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, file.name);
|
||||
if (opts.packet_id !== undefined) fd.append("packet_id", String(opts.packet_id));
|
||||
if (opts.purpose) fd.append("purpose", opts.purpose);
|
||||
return request<Asset>("/api/assets", { method: "POST", body: fd });
|
||||
},
|
||||
get: (id: string, signal?: AbortSignal) => api.get<Asset>(`/api/assets/${encodeURIComponent(id)}`, signal),
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
// Packet endpoints from the phase 2 contract.
|
||||
import { api, request } from "./client";
|
||||
import { asList, ifMatch, qs } from "./query";
|
||||
import type {
|
||||
AttachPagesBody,
|
||||
BomLine,
|
||||
FieldCas,
|
||||
Guarded,
|
||||
LogRow,
|
||||
PacketCard,
|
||||
PacketCreate,
|
||||
PacketHeader,
|
||||
PacketListQuery,
|
||||
PacketRead,
|
||||
TransitionBody,
|
||||
WorkOrder,
|
||||
} from "./types";
|
||||
|
||||
const base = (id: number) => `/api/packets/${encodeURIComponent(String(id))}`;
|
||||
|
||||
export const packetsApi = {
|
||||
list: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise<PacketCard[]> =>
|
||||
asList<PacketCard>(await api.get<unknown>(`/api/packets${qs({ limit: 500, ...q })}`, signal), "packets"),
|
||||
get: (id: number, signal?: AbortSignal) => api.get<PacketRead>(base(id), signal),
|
||||
log: async (id: number, opts: { limit?: number; before?: number } = {}, signal?: AbortSignal): Promise<LogRow[]> =>
|
||||
asList<LogRow>(await api.get<unknown>(`${base(id)}/log${qs({ limit: opts.limit ?? 300, before: opts.before })}`, signal), "log"),
|
||||
create: (body: PacketCreate) => api.post<PacketRead>("/api/packets", body),
|
||||
patch: (id: number, body: FieldCas<PacketHeader>) => api.patch<PacketRead>(base(id), body),
|
||||
transition: (id: number, body: TransitionBody) => api.post<PacketRead>(`${base(id)}/transitions`, body),
|
||||
putWorkOrders: (id: number, version: number, work_orders: WorkOrder[], extra: Guarded = {}) =>
|
||||
request<PacketRead>(`${base(id)}/work-orders`, { method: "PUT", body: { work_orders, ...extra }, headers: ifMatch(version) }),
|
||||
putBom: (id: number, version: number, bom: BomLine[], extra: Guarded = {}) =>
|
||||
request<PacketRead>(`${base(id)}/bom`, { method: "PUT", body: { bom, ...extra }, headers: ifMatch(version) }),
|
||||
attachPages: (id: number, body: AttachPagesBody) => api.post<PacketRead>(`${base(id)}/pages`, body),
|
||||
removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) =>
|
||||
api.delWithBody<PacketRead>(`${base(id)}/pages/${n}`, body),
|
||||
addDoc: (id: number, body: { asset_id: string; kind: string; title: string } & Guarded) =>
|
||||
api.post<PacketRead>(`${base(id)}/docs`, body),
|
||||
removeDoc: (id: number, docId: number, extra: Guarded = {}) =>
|
||||
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, extra),
|
||||
move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post<PacketRead>(`${base(id)}/move`, body),
|
||||
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<PacketRead | null>(base(id), body),
|
||||
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
|
||||
api.post<PacketRead>(`${base(id)}/vclosed`, { closed, ...extra }),
|
||||
databook: (id: number) => api.post<PacketRead>(`${base(id)}/databook`, {}),
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
// Project and unit endpoints from the phase 2 contract.
|
||||
import { api } from "./client";
|
||||
import { asList, qs } from "./query";
|
||||
import type { FieldCas, Guarded, NumberLookup, Project, ProjectCreate, Unit, UnitsCreate } from "./types";
|
||||
|
||||
export type ProjectShow = "active" | "open" | "hold" | "closed" | "all";
|
||||
|
||||
export interface ProjectFields {
|
||||
name: string | null;
|
||||
customer: string | null;
|
||||
po: string | null;
|
||||
due: string | null;
|
||||
status: Project["status"];
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface UnitFields {
|
||||
name: string | null;
|
||||
due: string | null;
|
||||
status: Unit["status"];
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
const pid = (id: number) => `/api/projects/${encodeURIComponent(String(id))}`;
|
||||
const uid = (id: number) => `/api/units/${encodeURIComponent(String(id))}`;
|
||||
|
||||
export const projectsApi = {
|
||||
list: async (q: string, status: ProjectShow = "active", signal?: AbortSignal): Promise<Project[]> =>
|
||||
asList<Project>(await api.get<unknown>(`/api/projects${qs({ q, status })}`, signal), "projects"),
|
||||
get: (id: number, signal?: AbortSignal) => api.get<Project>(pid(id), signal),
|
||||
create: (body: ProjectCreate & Guarded) => api.post<Project>("/api/projects", body),
|
||||
patch: (id: number, body: FieldCas<ProjectFields>) => api.patch<Project>(pid(id), body),
|
||||
renumber: (id: number, body: { number: string; reason: string } & Guarded) => api.post<Project>(`${pid(id)}/renumber`, body),
|
||||
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<null>(pid(id), body),
|
||||
addUnits: async (id: number, body: UnitsCreate & Guarded): Promise<Unit[]> =>
|
||||
asList<Unit>(await api.post<unknown>(`${pid(id)}/units`, body), "units"),
|
||||
units: async (projectId: number, q = "", signal?: AbortSignal): Promise<Unit[]> =>
|
||||
asList<Unit>(await api.get<unknown>(`/api/units${qs({ project_id: projectId, q })}`, signal), "units"),
|
||||
unit: (id: number, signal?: AbortSignal) => api.get<Unit>(uid(id), signal),
|
||||
patchUnit: (id: number, body: FieldCas<UnitFields>) => api.patch<Unit>(uid(id), body),
|
||||
renumberUnit: (id: number, body: { lot: string; reason: string } & Guarded) => api.post<Unit>(`${uid(id)}/renumber`, body),
|
||||
removeUnit: (id: number, body: { reason: string } & Guarded) => api.delWithBody<null>(uid(id), body),
|
||||
lookup: (n: string, signal?: AbortSignal) => api.get<NumberLookup>(`/api/lookup/number${qs({ n })}`, signal),
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// Query strings and list answers shared by the API modules.
|
||||
|
||||
/** "?a=1&b=x" from the defined, non-empty values. Empty when nothing is set. */
|
||||
export function qs(params: Record<string, string | number | boolean | null | undefined>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
/** A list answer may come bare or wrapped as {key: [...]}. Both give the array. */
|
||||
export function asList<T>(raw: unknown, key: string): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[];
|
||||
if (raw && typeof raw === "object" && Array.isArray((raw as Record<string, unknown>)[key])) {
|
||||
return (raw as Record<string, T[]>)[key] as T[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The If-Match header for a version check. */
|
||||
export function ifMatch(version: number): Record<string, string> {
|
||||
return { "If-Match": String(version) };
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// API shapes from the phase 2 design contract. IDs stay text where the contract says so.
|
||||
// Work order, lot, split, sub and part IDs are always strings. Timestamps are ISO strings.
|
||||
import type { Factor } from "../auth/types";
|
||||
|
||||
export const STATUSES = ["draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed"] as const;
|
||||
export type PacketStatus = (typeof STATUSES)[number];
|
||||
|
||||
export type ProjectStatus = "open" | "hold" | "closed";
|
||||
export type RouteKind = "databook" | "shipping";
|
||||
|
||||
/** Who references in packet answers. The frontend never gets emp_no or email here. */
|
||||
export interface WhoRef {
|
||||
id: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface WelderRef extends WhoRef {
|
||||
stamp: string | null;
|
||||
}
|
||||
|
||||
export interface StationRef {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DeptRef {
|
||||
code: string;
|
||||
name: string;
|
||||
weld?: boolean;
|
||||
}
|
||||
|
||||
/** A signature in a request body. The server checks it against the session user. */
|
||||
export interface SignBody {
|
||||
factor: Factor;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/** Packet counts per stage on project and unit rows. */
|
||||
export interface StageCounts {
|
||||
draft: number;
|
||||
warehouse: number;
|
||||
shop: number;
|
||||
qc: number;
|
||||
complete: number;
|
||||
filed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/* ---------- Projects and units ---------- */
|
||||
|
||||
export interface ProjectRef {
|
||||
id: number;
|
||||
number: string;
|
||||
name: string | null;
|
||||
customer?: string | null;
|
||||
po?: string | null;
|
||||
status?: ProjectStatus;
|
||||
due?: string | null;
|
||||
}
|
||||
|
||||
/** One row of GET /api/units?project_id= and of a project's `units`. */
|
||||
export interface Unit {
|
||||
id: number;
|
||||
project_id?: number;
|
||||
number: string;
|
||||
lot: string;
|
||||
name: string | null;
|
||||
due: string | null;
|
||||
due_effective: string | null;
|
||||
status: ProjectStatus;
|
||||
notes?: string | null;
|
||||
packets?: StageCounts;
|
||||
version: number;
|
||||
project?: ProjectRef;
|
||||
}
|
||||
|
||||
/** One row of GET /api/projects. GET /api/projects/{id} adds units and aliases. */
|
||||
export interface Project {
|
||||
id: number;
|
||||
number: string;
|
||||
name: string | null;
|
||||
customer: string | null;
|
||||
po: string | null;
|
||||
due: string | null;
|
||||
status: ProjectStatus;
|
||||
notes: string | null;
|
||||
source?: string;
|
||||
needs_details?: boolean;
|
||||
/** A count on list rows, the unit rows on GET /api/projects/{id}. */
|
||||
units: number | Unit[];
|
||||
packets?: StageCounts;
|
||||
aliases?: Array<string | { alias: string; kind?: string }>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ProjectCreate {
|
||||
number: string;
|
||||
name: string;
|
||||
customer: string;
|
||||
po: string;
|
||||
due: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface UnitsCreate {
|
||||
lot?: string;
|
||||
lots?: string[];
|
||||
name: string;
|
||||
due: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface FieldCas<T> {
|
||||
set: Partial<T>;
|
||||
base: Partial<T>;
|
||||
it_reason?: string;
|
||||
}
|
||||
|
||||
export interface NumberLookup {
|
||||
kind: "project" | "unit";
|
||||
id: number;
|
||||
number: string;
|
||||
alias: boolean;
|
||||
}
|
||||
|
||||
/* ---------- Packets ---------- */
|
||||
|
||||
export interface VclosedRef {
|
||||
at: string;
|
||||
by: WhoRef | null;
|
||||
}
|
||||
|
||||
/** One packet on a board. GET /api/packets -> {packets: [card...]}. */
|
||||
export interface PacketCard {
|
||||
id: number;
|
||||
version: number;
|
||||
unit: { id: number; number: string };
|
||||
project: { id: number; number: string; name: string | null };
|
||||
dept: DeptRef;
|
||||
op: string | null;
|
||||
title: string | null;
|
||||
customer: string | null;
|
||||
drawing: string | null;
|
||||
rev: string | null;
|
||||
status: PacketStatus;
|
||||
status_at: string | null;
|
||||
station: StationRef | null;
|
||||
welder: WelderRef | null;
|
||||
routed_to: RouteKind | null;
|
||||
vclosed: VclosedRef | null;
|
||||
source_kind: "visual" | "hand";
|
||||
flag: string | null;
|
||||
/** CO numbers, or a count. Both are handled. */
|
||||
open_cos: number[] | number;
|
||||
open_shorts: number;
|
||||
wh_stage: string | null;
|
||||
due_effective: string | null;
|
||||
updated_at: string | null;
|
||||
holder: string;
|
||||
can: string[];
|
||||
blocked: Record<string, string[]>;
|
||||
/** Not in the contract's card shape. Shown when the server sends it. */
|
||||
wo_count?: number;
|
||||
}
|
||||
|
||||
export interface PacketList {
|
||||
packets: PacketCard[];
|
||||
}
|
||||
|
||||
export interface PacketListQuery {
|
||||
status?: string;
|
||||
dept?: string;
|
||||
unit_id?: number;
|
||||
project_id?: number;
|
||||
q?: string;
|
||||
flag?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface WorkOrder {
|
||||
id?: number;
|
||||
wo_base: string;
|
||||
lot: string;
|
||||
split: string;
|
||||
sub: string;
|
||||
seq?: number | null;
|
||||
qty: number | string | null;
|
||||
target_hrs: number | string | null;
|
||||
op?: string | null;
|
||||
description?: string | null;
|
||||
visual_status?: string | null;
|
||||
gone_at?: string | null;
|
||||
row_version?: number;
|
||||
}
|
||||
|
||||
export interface PacketPage {
|
||||
n: number;
|
||||
type: "drawing" | "info" | "overlay" | string;
|
||||
asset_id: string | null;
|
||||
pn: number;
|
||||
dwg: string | null;
|
||||
rev: string | null;
|
||||
void: { co_id: number; at: string; new_n: number | null } | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BomShort {
|
||||
id: number;
|
||||
qty: number | string;
|
||||
qty_open: number | string;
|
||||
reason: string | null;
|
||||
ref: string | null;
|
||||
eta: string | null;
|
||||
note: string | null;
|
||||
opened_at: string | null;
|
||||
receipts?: unknown[];
|
||||
}
|
||||
|
||||
export interface BomLine {
|
||||
id?: number;
|
||||
item: number;
|
||||
qty: number | string | null;
|
||||
part_id: string | null;
|
||||
description: string | null;
|
||||
dwg_ref: string | null;
|
||||
cl_item?: string | null;
|
||||
length: string | null;
|
||||
material: string | null;
|
||||
heat?: string | null;
|
||||
na?: boolean;
|
||||
issued?: number | string | null;
|
||||
loc?: string | null;
|
||||
pulled?: boolean;
|
||||
mtr_asset_id?: string | null;
|
||||
src?: string | null;
|
||||
short?: BomShort | null;
|
||||
row_version?: number;
|
||||
}
|
||||
|
||||
export interface PacketDoc {
|
||||
id: number;
|
||||
asset_id: string;
|
||||
kind: string;
|
||||
title: string | null;
|
||||
added_at: string | null;
|
||||
added_by: WhoRef | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */
|
||||
export interface PacketRead {
|
||||
id: number;
|
||||
version: number;
|
||||
unit: {
|
||||
id: number;
|
||||
number: string;
|
||||
lot: string;
|
||||
name: string | null;
|
||||
project: { id: number; number: string; name: string | null; customer: string | null; po: string | null; status: ProjectStatus };
|
||||
due_effective: string | null;
|
||||
};
|
||||
dept: DeptRef;
|
||||
op: string | null;
|
||||
title: string | null;
|
||||
customer: string | null;
|
||||
drawing: string | null;
|
||||
rev: string | null;
|
||||
wps: string | null;
|
||||
status: PacketStatus;
|
||||
status_at: string | null;
|
||||
station: StationRef | null;
|
||||
welder: WelderRef | null;
|
||||
for_sup: WhoRef | null;
|
||||
heat_map: boolean | null;
|
||||
wh_skipped: boolean;
|
||||
wh_sent_at: string | null;
|
||||
routed_to: RouteKind | null;
|
||||
return_note: string | null;
|
||||
vclosed: VclosedRef | null;
|
||||
databook_at: string | null;
|
||||
source_kind: "visual" | "hand";
|
||||
source: Record<string, unknown>;
|
||||
drawing_state: string;
|
||||
flags: string[];
|
||||
visual: Record<string, unknown>;
|
||||
removed: { at: string; by: WhoRef | null; reason: string | null } | null;
|
||||
work_orders: WorkOrder[];
|
||||
pages: PacketPage[];
|
||||
bom: BomLine[];
|
||||
welds: unknown[];
|
||||
heat_tags: unknown[];
|
||||
heat_table: unknown[];
|
||||
stage: Record<string, unknown> | null;
|
||||
stages: unknown[];
|
||||
wh: Record<string, unknown> | null;
|
||||
qc: Record<string, unknown> | null;
|
||||
qc_history: unknown[];
|
||||
change_orders: unknown[];
|
||||
open_cos: number[];
|
||||
open_shorts: number;
|
||||
docs: PacketDoc[];
|
||||
seal: { sealed_at: string; key_id: string; broken_at: string | null } | null;
|
||||
can: string[];
|
||||
blocked: Record<string, string[]>;
|
||||
holder: string;
|
||||
checklist_items: Array<{ group: string; key: string; text: string }>;
|
||||
qc_items: unknown[];
|
||||
flag: string | null;
|
||||
}
|
||||
|
||||
/** One row of GET /api/packets/{id}/log, newest first. */
|
||||
export interface LogRow {
|
||||
id: number;
|
||||
at: string;
|
||||
by: WhoRef | null;
|
||||
role: string | null;
|
||||
action: string;
|
||||
note: string | null;
|
||||
it_reason: string | null;
|
||||
signed: boolean;
|
||||
}
|
||||
|
||||
/** POST /api/packets: a hand draft. */
|
||||
export interface PacketCreate {
|
||||
unit_id: number;
|
||||
dept_code: string;
|
||||
op: string;
|
||||
title: string;
|
||||
customer: string;
|
||||
drawing: string;
|
||||
rev: string;
|
||||
wps: string;
|
||||
heat_map?: boolean | null;
|
||||
work_orders: Array<Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub" | "qty" | "target_hrs"> & { op?: string; description?: string }>;
|
||||
bom: Array<Pick<BomLine, "item" | "qty" | "part_id" | "description" | "material" | "length" | "dwg_ref">>;
|
||||
}
|
||||
|
||||
/** The header fields PATCH /api/packets/{id} takes. */
|
||||
export interface PacketHeader {
|
||||
title: string | null;
|
||||
customer: string | null;
|
||||
wps: string | null;
|
||||
op: string | null;
|
||||
heat_map: boolean | null;
|
||||
for_sup_id: string | null;
|
||||
drawing: string | null;
|
||||
rev: string | null;
|
||||
dept_code: string;
|
||||
}
|
||||
|
||||
/** POST /api/packets/{id}/transitions. A transition carries only its own fields. */
|
||||
export interface TransitionBody {
|
||||
action: string;
|
||||
expect: { status: PacketStatus; dept: string };
|
||||
reason?: string;
|
||||
note?: string;
|
||||
dept?: string;
|
||||
station_id?: number;
|
||||
welder_id?: string;
|
||||
stamp?: string;
|
||||
routed_to?: RouteKind;
|
||||
book?: boolean;
|
||||
spool?: boolean;
|
||||
location?: string;
|
||||
for_sup_id?: string;
|
||||
sign?: SignBody;
|
||||
it_reason?: string;
|
||||
}
|
||||
|
||||
export interface AttachPagesBody {
|
||||
asset_id: string;
|
||||
type: "drawing";
|
||||
dwg?: string;
|
||||
rev?: string;
|
||||
sign?: SignBody;
|
||||
it_reason?: string;
|
||||
}
|
||||
|
||||
/* ---------- Assets ---------- */
|
||||
|
||||
/** POST /api/assets and GET /api/assets/{id}. */
|
||||
export interface Asset {
|
||||
id: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
pages: number | null;
|
||||
original_name: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Extra fields the guard adds on a retry: the IT reason and the signature. */
|
||||
export interface Guarded {
|
||||
it_reason?: string;
|
||||
sign?: SignBody;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { ApiError, errorCode, onApiError } from "../api/client";
|
||||
import { emitChanged } from "../lib/changes";
|
||||
import { authApi, departmentsApi } from "./api";
|
||||
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
|
||||
import { roleLabel as labelFor } from "./roles";
|
||||
@@ -203,6 +204,7 @@ export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs
|
||||
const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null;
|
||||
const presence = useEvents(sessionId, {
|
||||
onRevoked: () => endSession("This session was signed out from another place."),
|
||||
onChanged: emitChanged,
|
||||
});
|
||||
|
||||
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
|
||||
|
||||
@@ -173,8 +173,8 @@ export interface SigninAttempt {
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/** Stations may come as names or as rows. */
|
||||
export type StationEntry = string | { name: string; active?: boolean };
|
||||
/** Stations come as rows with their ids (ASSIGN needs the id). Older answers were plain names. */
|
||||
export type StationEntry = string | { id?: number; name: string; active?: boolean };
|
||||
|
||||
/** One row of GET /api/departments. */
|
||||
export interface Department {
|
||||
@@ -182,6 +182,7 @@ export interface Department {
|
||||
slug: string;
|
||||
name: string;
|
||||
kind: "support" | "shop";
|
||||
weld?: boolean;
|
||||
stations: StationEntry[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { PacketCard } from "../api/types";
|
||||
import { Card } from "./Card";
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
title: string;
|
||||
cards: PacketCard[];
|
||||
/** Shown when the column is empty. */
|
||||
empty: ReactNode;
|
||||
}
|
||||
|
||||
/** The old column(): a titled column of cards with a count, or the empty note. */
|
||||
export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
|
||||
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
return (
|
||||
<section className="col" aria-labelledby={id}>
|
||||
<h3 className="col-h" id={id}>
|
||||
{title}
|
||||
<span className="c">{cards.length}</span>
|
||||
</h3>
|
||||
{cards.length ? cards.map((p) => <Card key={p.id} p={p} />) : <div className="empty">{empty}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row of columns. */
|
||||
export function Board({ columns }: { columns: ColumnDef[] }) {
|
||||
return (
|
||||
<div className="board">
|
||||
{columns.map((c) => (
|
||||
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { PacketCard } from "../api/types";
|
||||
import { flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets";
|
||||
|
||||
/** One packet on a board. The old card(): job name and pill, title, department, meta, flag line. */
|
||||
export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
|
||||
const shorts = p.open_shorts || 0;
|
||||
const cos = openCoCount(p);
|
||||
return (
|
||||
<Link className="card" to={`/packets/${p.id}`} aria-label={`${jobName(p)}, ${statusLabel(p.status)}`}>
|
||||
<div className="r1">
|
||||
<span className="job">{jobName(p)}</span>
|
||||
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
|
||||
</div>
|
||||
<div className="tt">{p.title || "Untitled"}</div>
|
||||
<div className="small dept">{p.dept.name}</div>
|
||||
<div className="meta">
|
||||
<span>
|
||||
Dwg <b className="mono">{p.drawing || "-"}</b>
|
||||
{p.rev ? <> R{p.rev}</> : null}
|
||||
</span>
|
||||
{p.wo_count !== undefined ? (
|
||||
<span>
|
||||
<b>{p.wo_count}</b> WO
|
||||
</span>
|
||||
) : null}
|
||||
{p.station ? (
|
||||
<span>
|
||||
<b>{p.station.name}</b>
|
||||
{p.welder ? ` · ${p.welder.display_name}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{p.status === "complete" && p.routed_to ? (
|
||||
<span>
|
||||
To <b>{p.routed_to === "databook" ? "Clerical (data book)" : "Shipping"}</b>
|
||||
</span>
|
||||
) : null}
|
||||
{shorts ? <span className="pill st-review">{shorts} short</span> : null}
|
||||
{cos ? <span className="pill st-review">CO {cos}</span> : null}
|
||||
</div>
|
||||
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
||||
{extra}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PickUnit, type UnitPick } from "./PickUnit";
|
||||
|
||||
export type BoardView = "board" | "list";
|
||||
|
||||
interface FindBarProps {
|
||||
q: string;
|
||||
onQ: (q: string) => void;
|
||||
pick: UnitPick;
|
||||
onPick: (v: UnitPick) => void;
|
||||
view: BoardView;
|
||||
onView: (v: BoardView) => void;
|
||||
/** "Showing n packets". */
|
||||
count: number | null;
|
||||
}
|
||||
|
||||
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
|
||||
export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarProps) {
|
||||
return (
|
||||
<section className="fltbar" aria-label="Find jobs">
|
||||
<div className="fltrow">
|
||||
<label className="f fltq">
|
||||
<span>Find a job</span>
|
||||
<input
|
||||
className="in"
|
||||
type="search"
|
||||
placeholder="Project, unit, lot, drawing, WO, customer, PO, title"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={q}
|
||||
onChange={(e) => onQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" && q) {
|
||||
e.stopPropagation();
|
||||
onQ("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="f fltp">
|
||||
<PickUnit value={pick} onChange={onPick} />
|
||||
</div>
|
||||
<div className="fltbtns">
|
||||
<div className="seg" role="group" aria-label="View">
|
||||
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
|
||||
Board
|
||||
</button>
|
||||
<button type="button" aria-pressed={view === "list"} onClick={() => onView("list")}>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{count !== null ? (
|
||||
<div className="fltrow2">
|
||||
<span className="grow" />
|
||||
<span className="small muted">
|
||||
Showing <b>{count}</b> packet{count === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface Kpi {
|
||||
n: ReactNode;
|
||||
label: string;
|
||||
/** Turns pink when above zero. */
|
||||
alert?: boolean;
|
||||
}
|
||||
|
||||
/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. */
|
||||
export function Kpis({ items, label = "Numbers" }: { items: Kpi[]; label?: string }) {
|
||||
return (
|
||||
<div className="kpis" role="group" aria-label={label}>
|
||||
{items.map((k) => {
|
||||
const zero = String(k.n) === "0";
|
||||
const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div key={k.label} className={cls} aria-label={`${k.label}: ${String(k.n)}`}>
|
||||
<div className="n">{k.n}</div>
|
||||
<div className="l">{k.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import type { Project, ProjectRef, Unit } from "../api/types";
|
||||
import { errorText } from "../lib/format";
|
||||
import { projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets";
|
||||
|
||||
export interface UnitPick {
|
||||
project: ProjectRef | null;
|
||||
unit: Unit | null;
|
||||
}
|
||||
|
||||
interface PickUnitProps {
|
||||
value: UnitPick;
|
||||
onChange: (v: UnitPick) => void;
|
||||
/** A packet must go in a unit: no "All units" choice, and closed units are left out. */
|
||||
requireUnit?: boolean;
|
||||
/** Label of the first step. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Two steps: find the project, then pick one of its units. */
|
||||
export function PickUnit({ value, onChange, requireUnit = false, label = "Project" }: PickUnitProps) {
|
||||
const id = useId();
|
||||
const [q, setQ] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<Project[] | null>(null);
|
||||
const [units, setUnits] = useState<Unit[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const projectId = value.project?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q]);
|
||||
|
||||
// Step one: projects matching the search, while no project is chosen.
|
||||
useEffect(() => {
|
||||
if (projectId !== null) return;
|
||||
const ctl = new AbortController();
|
||||
projectsApi
|
||||
.list(query, "active", ctl.signal)
|
||||
.then((list) => {
|
||||
if (!ctl.signal.aborted) setHits(list);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctl.signal.aborted) setError(errorText(err));
|
||||
});
|
||||
return () => ctl.abort();
|
||||
}, [query, projectId]);
|
||||
|
||||
// Step two: the chosen project's units.
|
||||
useEffect(() => {
|
||||
if (projectId === null) {
|
||||
setUnits(null);
|
||||
return;
|
||||
}
|
||||
const ctl = new AbortController();
|
||||
projectsApi
|
||||
.units(projectId, "", ctl.signal)
|
||||
.then((list) => {
|
||||
if (!ctl.signal.aborted) setUnits(list);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctl.signal.aborted) setError(errorText(err));
|
||||
});
|
||||
return () => ctl.abort();
|
||||
}, [projectId]);
|
||||
|
||||
const choose = (p: Project) => {
|
||||
setError(null);
|
||||
onChange({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null });
|
||||
};
|
||||
|
||||
if (!value.project) {
|
||||
const shown = (hits ?? []).slice(0, 8);
|
||||
return (
|
||||
<div className="pick">
|
||||
<label className="f">
|
||||
{label}
|
||||
<input
|
||||
className="in"
|
||||
type="search"
|
||||
placeholder="Find a project: number, name, customer, PO"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error ? <div className="small due over">{error}</div> : null}
|
||||
{hits === null ? null : shown.length ? (
|
||||
<div className="projhits" role="group" aria-label="Projects found">
|
||||
{shown.map((p) => (
|
||||
<button key={p.id} type="button" className="projhit" onClick={() => choose(p)}>
|
||||
<b className="mono">{p.number}</b> {p.name ?? ""}
|
||||
<span className="muted small">{p.customer ?? ""}</span>
|
||||
<span className={projectPillClass(p.status)}>{PROJECT_STATUS_LABEL[p.status]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="small muted">{query ? `Nothing matches "${query}".` : "No open projects yet."}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = (units ?? []).filter((u) => !requireUnit || u.status !== "closed" || u.id === value.unit?.id);
|
||||
return (
|
||||
<div className="pick">
|
||||
<div className="pickp">
|
||||
<span className="small muted">{label}</span>
|
||||
<b className="mono">{value.project.number}</b> <span>{value.project.name ?? ""}</span>
|
||||
<button className="lnk small" type="button" onClick={() => onChange({ project: null, unit: null })}>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<label className="f" htmlFor={`${id}-unit`}>
|
||||
Unit
|
||||
</label>
|
||||
{units === null ? (
|
||||
<div className="small muted">Loading units</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="small muted">This project has no open units. Add one from the project page first.</div>
|
||||
) : (
|
||||
<select
|
||||
id={`${id}-unit`}
|
||||
className="in"
|
||||
value={value.unit ? String(value.unit.id) : ""}
|
||||
onChange={(e) => {
|
||||
const u = list.find((x) => String(x.id) === e.target.value) ?? null;
|
||||
onChange({ project: value.project, unit: u });
|
||||
}}
|
||||
>
|
||||
<option value="">{requireUnit ? "Choose a unit" : "All units"}</option>
|
||||
{list.map((u) => (
|
||||
<option key={u.id} value={String(u.id)}>
|
||||
{u.number}
|
||||
{u.name ? ` · ${u.name}` : ""}
|
||||
{u.status === "hold" ? " (on hold)" : u.status === "closed" ? " (closed)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{error ? <div className="small due over">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Sheet } from "./Sheet";
|
||||
|
||||
export interface ReasonOptions {
|
||||
title: string;
|
||||
sub?: string | undefined;
|
||||
/** The field label. */
|
||||
label?: string;
|
||||
/** A line under the title. */
|
||||
note?: string | undefined;
|
||||
placeholder?: string | undefined;
|
||||
/** Shortest reason accepted. 0 makes the field optional. */
|
||||
min?: number;
|
||||
max?: number;
|
||||
submitLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface ReasonSheetProps extends ReasonOptions {
|
||||
onSubmit: (reason: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Asks for a reason, or an optional note, before a step. */
|
||||
export function ReasonSheet({
|
||||
title,
|
||||
sub,
|
||||
label = "Reason",
|
||||
note,
|
||||
placeholder,
|
||||
min = 1,
|
||||
max = 500,
|
||||
submitLabel = "Continue",
|
||||
danger = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ReasonSheetProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const t = text.trim();
|
||||
if (t.length < min) {
|
||||
setError(min <= 1 ? "Give a reason." : `Give a reason of at least ${min} characters.`);
|
||||
return;
|
||||
}
|
||||
onSubmit(t);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet title={title} sub={sub} onClose={onCancel}>
|
||||
{note ? <div className="small">{note}</div> : null}
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<label className="f">
|
||||
{label}
|
||||
<input className="in" value={text} maxLength={max} placeholder={placeholder} onChange={(e) => setText(e.target.value)} />
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className={danger ? "btn pink" : "btn pri"} type="submit">
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
|
||||
interface SheetProps {
|
||||
title: string;
|
||||
/** Small text next to the title, like the job name. */
|
||||
sub?: string | undefined;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** The old sheets were 560 px for short forms and 720 px or more for long ones. */
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
/** Open sheets, innermost last. Only the innermost one answers Escape. */
|
||||
const open: string[] = [];
|
||||
|
||||
/** A modal sheet over a scrim. Escape and a click on the scrim close it. The first field gets the focus. */
|
||||
export function Sheet({ title, sub, onClose, children, wide = false }: SheetProps) {
|
||||
const id = useId();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
closeRef.current = onClose;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
const first = el?.querySelector<HTMLElement>("input:not([type=hidden]):not([type=file]):not([disabled]), select, textarea");
|
||||
(first ?? el)?.focus();
|
||||
open.push(id);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open[open.length - 1] === id) {
|
||||
e.stopPropagation();
|
||||
closeRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
const i = open.lastIndexOf(id);
|
||||
if (i >= 0) open.splice(i, 1);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="scrim"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div ref={ref} className={wide ? "sheet" : "sheet narrow"} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
|
||||
<div className="sec-h flush">
|
||||
<h2 id={id}>{title}</h2>
|
||||
{sub ? <span className="ct">{sub}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { ApiError, errorCode } from "../api/client";
|
||||
import type { SignBody } from "../api/types";
|
||||
import type { Factor } from "../auth/types";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { errorText } from "../lib/format";
|
||||
import { applyKey, type KeypadKey } from "../lib/keypad";
|
||||
import { Keypad } from "./Keypad";
|
||||
import { Sheet } from "./Sheet";
|
||||
|
||||
const PIN_MAX = 12;
|
||||
|
||||
interface SignatureSheetProps {
|
||||
/** What the signature means, from the rule. */
|
||||
meaning: string;
|
||||
sub?: string | undefined;
|
||||
/** Sends the step with the signature. A wrong secret keeps the sheet open. */
|
||||
onSign: (sign: SignBody) => Promise<void>;
|
||||
/** Any other failure. The caller closes the sheet. */
|
||||
onFail: (err: unknown) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function attemptsLeft(err: unknown): number | null {
|
||||
if (err instanceof ApiError && err.body && typeof err.body === "object" && "attempts_left" in err.body) {
|
||||
const n = Number((err.body as { attempts_left: unknown }).attempts_left);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Signs a step with the user's PIN or password. */
|
||||
export function SignatureSheet({ meaning, sub, onSign, onFail, onCancel }: SignatureSheetProps) {
|
||||
const auth = useAuth();
|
||||
const me = auth.me;
|
||||
const hasPin = me ? me.pin.set && !me.pin.disabled : false;
|
||||
const [factor, setFactor] = useState<Factor>(hasPin ? "pin" : "password");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [left, setLeft] = useState<number | null>(null);
|
||||
const [missed, setMissed] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [factor]);
|
||||
|
||||
const switchTo = (f: Factor) => {
|
||||
setFactor(f);
|
||||
setSecret("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!secret || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSign({ factor, secret });
|
||||
} catch (err) {
|
||||
const code = errorCode(err);
|
||||
if (code === "session_ended") {
|
||||
// The auth layer signs out on this 401 too. Make sure, with the server's words.
|
||||
auth.endSession(errorText(err));
|
||||
return;
|
||||
}
|
||||
if (code === "wrong_secret" || code === "bad_secret") {
|
||||
setError(errorText(err));
|
||||
setLeft(attemptsLeft(err));
|
||||
setMissed(true);
|
||||
setSecret("");
|
||||
setBusy(false);
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
onFail(err);
|
||||
}
|
||||
};
|
||||
|
||||
const onKey = (k: KeypadKey) => setSecret((v) => applyKey(v, k, PIN_MAX));
|
||||
|
||||
return (
|
||||
<Sheet title="Sign" sub={sub} onClose={onCancel}>
|
||||
<div className="sigmean">
|
||||
<div className="eyebrow">This signature means</div>
|
||||
<div className="tt">{meaning}</div>
|
||||
</div>
|
||||
<div className="small">
|
||||
You are signing as <b>{me?.user.display_name ?? "yourself"}</b>.
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
{left !== null ? <div className="small">{left === 1 ? "1 attempt left." : `${left} attempts left.`}</div> : null}
|
||||
{missed ? <div className="small">Three wrong attempts end the session.</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<form className="auth-form" onSubmit={submit} noValidate autoComplete="off">
|
||||
{factor === "pin" ? (
|
||||
<>
|
||||
<label className="f">
|
||||
PIN
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="in big"
|
||||
name="sign-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
maxLength={PIN_MAX}
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value.replace(/\D/g, "").slice(0, PIN_MAX))}
|
||||
/>
|
||||
</label>
|
||||
<Keypad onKey={onKey} disabled={busy} label="Keypad, signature PIN" />
|
||||
</>
|
||||
) : (
|
||||
<label className="f">
|
||||
Password
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="in"
|
||||
name="sign-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="sheet-acts">
|
||||
{factor === "pin" ? (
|
||||
<button className="lnk small" type="button" onClick={() => switchTo("password")}>
|
||||
Use your password instead
|
||||
</button>
|
||||
) : hasPin ? (
|
||||
<button className="lnk small" type="button" onClick={() => switchTo("pin")}>
|
||||
Use your PIN instead
|
||||
</button>
|
||||
) : null}
|
||||
<span className="grow" />
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy || !secret}>
|
||||
{busy ? "Signing" : "Sign"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { ApiError, errorCode } from "../api/client";
|
||||
import type { Guarded, SignBody } from "../api/types";
|
||||
import { ReasonSheet, type ReasonOptions } from "./ReasonSheet";
|
||||
import { SignatureSheet } from "./SignatureSheet";
|
||||
|
||||
type Ask =
|
||||
| { kind: "reason"; opts: ReasonOptions; resolve: (r: string | null) => void }
|
||||
| {
|
||||
kind: "sign";
|
||||
meaning: string;
|
||||
sub: string | undefined;
|
||||
attempt: (sign: SignBody) => Promise<unknown>;
|
||||
resolve: (r: { ok: true; value: unknown } | { ok: false }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
|
||||
export interface RunOptions {
|
||||
/** The signature meaning when the step is known to need one. Otherwise the sheet opens on 400 signature_required. */
|
||||
sign?: string | undefined;
|
||||
/** Shown next to the sheet titles, like the job name. */
|
||||
sub?: string | undefined;
|
||||
}
|
||||
|
||||
export interface Guard {
|
||||
/**
|
||||
* Runs a call. On 400 it_reason_required it asks for the IT reason and retries with `it_reason`.
|
||||
* On 400 signature_required it opens the signature sheet and retries with `sign`.
|
||||
* Resolves undefined when the user cancels. Other errors are thrown.
|
||||
*/
|
||||
run: <T>(fn: (g: Guarded) => Promise<T>, opts?: RunOptions) => Promise<T | undefined>;
|
||||
/** Asks for a reason. Null when cancelled. */
|
||||
askReason: (opts: ReasonOptions) => Promise<string | null>;
|
||||
/** The open sheet, if any. Render it once in the screen. */
|
||||
sheet: ReactNode;
|
||||
}
|
||||
|
||||
export const IT_REASON: ReasonOptions = {
|
||||
title: "IT reason",
|
||||
label: "Why IT is making this change",
|
||||
note: "You are changing this as IT. The reason is recorded with the change.",
|
||||
min: 8,
|
||||
submitLabel: "Continue",
|
||||
};
|
||||
|
||||
function meaningOf(err: unknown): string {
|
||||
if (err instanceof ApiError && err.body && typeof err.body === "object" && "meaning" in err.body) {
|
||||
const m = (err.body as { meaning: unknown }).meaning;
|
||||
if (typeof m === "string" && m) return m;
|
||||
}
|
||||
return "Sign this step";
|
||||
}
|
||||
|
||||
/** The IT reason and signature retries, as sheets. */
|
||||
export function useGuard(): Guard {
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
|
||||
const askReason = useCallback(
|
||||
(opts: ReasonOptions) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
setAsk({ kind: "reason", opts, resolve });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const askSign = useCallback(
|
||||
<T,>(meaning: string, sub: string | undefined, attempt: (sign: SignBody) => Promise<T>) =>
|
||||
new Promise<{ ok: true; value: T } | { ok: false }>((resolve, reject) => {
|
||||
setAsk({
|
||||
kind: "sign",
|
||||
meaning,
|
||||
sub,
|
||||
attempt,
|
||||
resolve: resolve as (r: { ok: true; value: unknown } | { ok: false }) => void,
|
||||
reject,
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async <T,>(fn: (g: Guarded) => Promise<T>, opts: RunOptions = {}): Promise<T | undefined> => {
|
||||
let itReason: string | undefined;
|
||||
let meaning = opts.sign;
|
||||
for (let round = 0; round < 4; round++) {
|
||||
const call = (sign?: SignBody) => {
|
||||
const g: Guarded = {};
|
||||
if (itReason) g.it_reason = itReason;
|
||||
if (sign) g.sign = sign;
|
||||
return fn(g);
|
||||
};
|
||||
try {
|
||||
if (meaning) {
|
||||
const r = await askSign(meaning, opts.sub, call);
|
||||
return r.ok ? r.value : undefined;
|
||||
}
|
||||
return await call();
|
||||
} catch (err) {
|
||||
const code = errorCode(err);
|
||||
if (code === "it_reason_required" && !itReason) {
|
||||
const r = await askReason({ ...IT_REASON, sub: opts.sub });
|
||||
if (r === null) return undefined;
|
||||
itReason = r;
|
||||
continue;
|
||||
}
|
||||
if (code === "signature_required" && !meaning) {
|
||||
meaning = meaningOf(err);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[askReason, askSign],
|
||||
);
|
||||
|
||||
let sheet: ReactNode = null;
|
||||
if (ask?.kind === "reason") {
|
||||
sheet = (
|
||||
<ReasonSheet
|
||||
{...ask.opts}
|
||||
onSubmit={(r) => {
|
||||
setAsk(null);
|
||||
ask.resolve(r);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAsk(null);
|
||||
ask.resolve(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (ask?.kind === "sign") {
|
||||
sheet = (
|
||||
<SignatureSheet
|
||||
meaning={ask.meaning}
|
||||
sub={ask.sub}
|
||||
onSign={async (sign) => {
|
||||
const value = await ask.attempt(sign);
|
||||
setAsk(null);
|
||||
ask.resolve({ ok: true, value });
|
||||
}}
|
||||
onFail={(err) => {
|
||||
setAsk(null);
|
||||
ask.reject(err);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAsk(null);
|
||||
ask.resolve({ ok: false });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return useMemo(() => ({ run, askReason, sheet }), [run, askReason, sheet]);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// What the screens offer. The server decides on every request; this only hides what cannot work.
|
||||
|
||||
/** Clerical (or a Clerical supervisor) sets up projects and units. Admin can, with an IT reason. */
|
||||
export function canEditProjects(roles: readonly string[]): boolean {
|
||||
return roles.some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin");
|
||||
}
|
||||
|
||||
/** Renumbering a project or unit needs a Clerical supervisor, or admin. */
|
||||
export function canRenumber(roles: readonly string[]): boolean {
|
||||
return roles.some((r) => r === "clerical_supervisor" || r === "admin");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// The `changed` events from /api/events, fanned out to the open screens.
|
||||
// AuthProvider owns the one EventSource and feeds every payload in here.
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface ChangedEvent {
|
||||
event?: string;
|
||||
packet_id?: number;
|
||||
version?: number;
|
||||
status?: string;
|
||||
dept?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type Listener = (ev: ChangedEvent) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function emitChanged(payload: unknown): void {
|
||||
const ev: ChangedEvent = payload && typeof payload === "object" ? (payload as ChangedEvent) : {};
|
||||
for (const fn of listeners) fn(ev);
|
||||
}
|
||||
|
||||
export function onChanged(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `fn` after `changed` events. With `delayMs`, a burst of events gives one call
|
||||
* `delayMs` after the last one. `filter` drops events the screen does not care about.
|
||||
*/
|
||||
export function useChanged(fn: () => void, opts: { delayMs?: number; filter?: (ev: ChangedEvent) => boolean } = {}): void {
|
||||
const fnRef = useRef(fn);
|
||||
const filterRef = useRef(opts.filter);
|
||||
useEffect(() => {
|
||||
fnRef.current = fn;
|
||||
filterRef.current = opts.filter;
|
||||
});
|
||||
const delay = opts.delayMs ?? 500;
|
||||
useEffect(() => {
|
||||
let t: number | undefined;
|
||||
const off = onChanged((ev) => {
|
||||
if (filterRef.current && !filterRef.current(ev)) return;
|
||||
window.clearTimeout(t);
|
||||
t = window.setTimeout(() => fnRef.current(), delay);
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [delay]);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Status labels, pills, job names and due dates. Copy from the old build where it had one.
|
||||
import type { PacketCard, PacketRead, PacketStatus, ProjectStatus, StageCounts } from "../api/types";
|
||||
|
||||
export const STATUS_LABEL: Record<PacketStatus, string> = {
|
||||
draft: "Ready to release",
|
||||
warehouse: "With warehouse",
|
||||
ready: "Ready to assign",
|
||||
assigned: "Sent to station",
|
||||
in_progress: "Working",
|
||||
review: "Supervisor review",
|
||||
qc: "Q.C. inspection",
|
||||
complete: "Routed",
|
||||
filed: "Filed in data book",
|
||||
};
|
||||
|
||||
export function statusLabel(s: string): string {
|
||||
return (STATUS_LABEL as Record<string, string>)[s] ?? s;
|
||||
}
|
||||
|
||||
/** The pill class for a status: "pill st-<status>". */
|
||||
export function pillClass(s: string): string {
|
||||
return `pill st-${s}`;
|
||||
}
|
||||
|
||||
export const SHOP: readonly PacketStatus[] = ["ready", "assigned", "in_progress", "review"];
|
||||
export const IN_SHOP_OR_QC: readonly PacketStatus[] = [...SHOP, "qc"];
|
||||
|
||||
/** "L999001 · WELD-PART". */
|
||||
export function jobName(p: Pick<PacketCard, "unit" | "op"> | Pick<PacketRead, "unit" | "op">): string {
|
||||
return p.op ? `${p.unit.number} · ${p.op}` : p.unit.number;
|
||||
}
|
||||
|
||||
export const PROJECT_STATUS_LABEL: Record<ProjectStatus, string> = { open: "Open", hold: "On hold", closed: "Closed" };
|
||||
|
||||
/** The old projPill colours: open green, on hold pink, closed grey. */
|
||||
export function projectPillClass(s: ProjectStatus): string {
|
||||
return `pill ${s === "open" ? "st-in_progress" : s === "hold" ? "st-review" : "st-filed"}`;
|
||||
}
|
||||
|
||||
/** A flag text that names a hard problem shows in pink, the rest in amber. */
|
||||
export function flagClass(flag: string): string {
|
||||
return /couldn't|not in|mismatch|No approved|Not released/i.test(flag) ? "due over" : "due soon";
|
||||
}
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** The old dueInfo(): "Due Oct 20 '26 · 4 d left", with a class for colour. */
|
||||
export function dueInfo(due: string | null | undefined, closed = false, today: Date = new Date()): { txt: string; cls: string } {
|
||||
if (!due) return { txt: "No due date", cls: "due muted" };
|
||||
const d = new Date(`${due.slice(0, 10)}T12:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return { txt: `Due ${due}`, cls: "due" };
|
||||
const noon = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 12);
|
||||
const days = Math.round((d.getTime() - noon.getTime()) / 86_400_000);
|
||||
const lab = `${MONTHS[d.getMonth()] ?? ""} ${d.getDate()} '${String(d.getFullYear()).slice(2)}`;
|
||||
if (closed) return { txt: `Due ${lab}`, cls: "due muted" };
|
||||
if (days < 0) return { txt: `Due ${lab} · ${-days} d overdue`, cls: "due over" };
|
||||
if (days <= 7) return { txt: `Due ${lab} · ${days === 0 ? "today" : `${days} d left`}`, cls: "due soon" };
|
||||
return { txt: `Due ${lab} · ${days} d left`, cls: "due" };
|
||||
}
|
||||
|
||||
/** The open change orders on a card, as a count. */
|
||||
export function openCoCount(p: Pick<PacketCard, "open_cos">): number {
|
||||
return Array.isArray(p.open_cos) ? p.open_cos.length : Number(p.open_cos) || 0;
|
||||
}
|
||||
|
||||
/** Sum of the work orders' target hours. */
|
||||
export function targetHours(p: Pick<PacketRead, "work_orders">): number {
|
||||
return p.work_orders.reduce((a, w) => a + (Number(w.target_hrs) || 0), 0);
|
||||
}
|
||||
|
||||
/** "001" from "1", "7A" stays "7A". The server does the same. */
|
||||
export function normLot(v: string): string {
|
||||
const t = v.trim().toUpperCase();
|
||||
return /^\d{1,3}$/.test(t) ? t.padStart(3, "0") : t;
|
||||
}
|
||||
|
||||
export const RX_LOT = /^[A-Z0-9]{1,3}$/;
|
||||
export const RX_PROJECT = /^[A-Z0-9][A-Z0-9-]{0,19}$/;
|
||||
|
||||
/** Lots counting up from a numeric start: ("008", 3) -> 008, 009, 010. Null when the start is not digits. */
|
||||
export function lotSequence(start: string, count: number): string[] | null {
|
||||
const s = start.trim();
|
||||
if (!/^\d{1,3}$/.test(s) || !Number.isInteger(count) || count < 1) return null;
|
||||
const b = Number(s);
|
||||
if (b + count - 1 > 999) return null;
|
||||
return Array.from({ length: count }, (_, i) => String(b + i).padStart(3, "0"));
|
||||
}
|
||||
|
||||
/** The next free numeric lot after the ones in use. */
|
||||
export function nextLot(lots: readonly string[]): string {
|
||||
const nums = lots.filter((l) => /^\d+$/.test(l)).map(Number);
|
||||
const n = nums.length ? Math.max(...nums) + 1 : 1;
|
||||
return String(Math.min(n, 999)).padStart(3, "0");
|
||||
}
|
||||
|
||||
/** Packet counts with every stage present. */
|
||||
export function stageCounts(c: Partial<StageCounts> | undefined | null): StageCounts {
|
||||
return { draft: 0, warehouse: 0, shop: 0, qc: 0, complete: 0, filed: 0, total: 0, ...(c ?? {}) };
|
||||
}
|
||||
|
||||
/** Counts of a list of cards, the same way. */
|
||||
export function countCards(list: readonly Pick<PacketCard, "status">[]): StageCounts {
|
||||
const c = stageCounts(null);
|
||||
for (const p of list) {
|
||||
c.total++;
|
||||
if (p.status === "draft") c.draft++;
|
||||
else if (p.status === "warehouse") c.warehouse++;
|
||||
else if ((SHOP as readonly string[]).includes(p.status)) c.shop++;
|
||||
else if (p.status === "qc") c.qc++;
|
||||
else if (p.status === "complete") c.complete++;
|
||||
else if (p.status === "filed") c.filed++;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// pdf.js, loaded on first use. The worker is a same-origin file from the build (CSP worker-src 'self').
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
|
||||
type PdfJs = typeof import("pdfjs-dist");
|
||||
|
||||
let lib: Promise<PdfJs> | null = null;
|
||||
|
||||
export function loadPdfJs(): Promise<PdfJs> {
|
||||
if (!lib) {
|
||||
lib = import("pdfjs-dist").then((m) => {
|
||||
m.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
return m;
|
||||
});
|
||||
lib.catch(() => {
|
||||
lib = null;
|
||||
});
|
||||
}
|
||||
return lib;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
promise: Promise<PDFDocumentProxy>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const docs = new Map<string, Entry>();
|
||||
const MAX_DOCS = 6;
|
||||
|
||||
/**
|
||||
* Opens a PDF from a same-origin URL, once per URL. The session cookie rides along.
|
||||
* WebAssembly decoders stay off: the CSP does not allow wasm, and drawings rarely need them.
|
||||
*/
|
||||
export function openPdf(url: string): Promise<PDFDocumentProxy> {
|
||||
const hit = docs.get(url);
|
||||
if (hit) return hit.promise;
|
||||
let destroy = () => undefined as void;
|
||||
const promise = loadPdfJs().then((m) => {
|
||||
const task = m.getDocument({ url, withCredentials: true, useWasm: false });
|
||||
destroy = () => void task.destroy().catch(() => undefined);
|
||||
return task.promise;
|
||||
});
|
||||
const entry: Entry = { promise, destroy: () => destroy() };
|
||||
docs.set(url, entry);
|
||||
promise.catch(() => docs.delete(url));
|
||||
// Keep a few documents open; drop the oldest.
|
||||
while (docs.size > MAX_DOCS) {
|
||||
const first = docs.keys().next().value;
|
||||
if (first === undefined) break;
|
||||
docs.get(first)?.destroy();
|
||||
docs.delete(first);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { departmentsApi } from "../auth/api";
|
||||
import type { Department } from "../auth/types";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
|
||||
/** The shop departments from /api/departments, in code order. */
|
||||
export function useShopDepartments(): Department[] {
|
||||
const { departments } = useAuth();
|
||||
const [own, setOwn] = useState<Department[] | null>(null);
|
||||
useEffect(() => {
|
||||
if (departments.length) return;
|
||||
const ctl = new AbortController();
|
||||
departmentsApi
|
||||
.list(ctl.signal)
|
||||
.then((d) => {
|
||||
if (!ctl.signal.aborted) setOwn(Array.isArray(d) ? d : []);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => ctl.abort();
|
||||
}, [departments.length]);
|
||||
const list = departments.length ? departments : (own ?? []);
|
||||
return list.filter((d) => d.kind === "shop").sort((a, b) => a.code.localeCompare(b.code));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PacketPage } from "../api/types";
|
||||
import { openPdf } from "../lib/pdf";
|
||||
|
||||
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||
|
||||
/** The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet. */
|
||||
export function DrawingViewer({ pages }: { pages: PacketPage[] }) {
|
||||
const [sel, setSel] = useState(0);
|
||||
const [zi, setZi] = useState(2);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const page = pages[Math.min(sel, pages.length - 1)];
|
||||
const zoom = ZOOMS[zi] ?? 1;
|
||||
const url = page?.url ?? "";
|
||||
const pn = page?.pn ?? 1;
|
||||
|
||||
// Fit to width: follow the frame's width.
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setWidth(el.clientWidth);
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || !width) return;
|
||||
let cancelled = false;
|
||||
let task: { cancel: () => void; promise: Promise<unknown> } | null = null;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
openPdf(url)
|
||||
.then((doc) => doc.getPage(Math.max(1, Math.min(pn || 1, doc.numPages))))
|
||||
.then(async (pg) => {
|
||||
if (cancelled) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const base = pg.getViewport({ scale: 1 });
|
||||
const scale = (Math.max(200, width - 2) / base.width) * zoom;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const vp = pg.getViewport({ scale: scale * ratio });
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = `${Math.floor(vp.width / ratio)}px`;
|
||||
canvas.style.height = `${Math.floor(vp.height / ratio)}px`;
|
||||
task = pg.render({ canvas, viewport: vp });
|
||||
await task.promise;
|
||||
if (!cancelled) setBusy(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (err && typeof err === "object" && "name" in err && (err as { name: string }).name === "RenderingCancelledException") return;
|
||||
setBusy(false);
|
||||
setError("The drawing could not be shown. Open it in a new tab instead.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task?.cancel();
|
||||
};
|
||||
}, [url, pn, width, zoom]);
|
||||
|
||||
if (!page) return <div className="empty">No drawing attached yet.</div>;
|
||||
|
||||
return (
|
||||
<div className="pdfv">
|
||||
<div className="pdfv-bar">
|
||||
<div className="seg" role="group" aria-label="Zoom">
|
||||
<button type="button" aria-label="Zoom out" disabled={zi === 0} onClick={() => setZi((z) => Math.max(0, z - 1))}>
|
||||
−
|
||||
</button>
|
||||
<button type="button" aria-label="Fit to width" aria-pressed={zoom === 1} onClick={() => setZi(2)}>
|
||||
Fit
|
||||
</button>
|
||||
<button type="button" aria-label="Zoom in" disabled={zi === ZOOMS.length - 1} onClick={() => setZi((z) => Math.min(ZOOMS.length - 1, z + 1))}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="small muted mono">{Math.round(zoom * 100)}%</span>
|
||||
<span className="grow" />
|
||||
<a className="btn sm ghost" href={page.url} target="_blank" rel="noopener noreferrer">
|
||||
Open in a new tab
|
||||
</a>
|
||||
</div>
|
||||
<div className="pdfv-body">
|
||||
<ol className="pdfv-pages" aria-label="Pages">
|
||||
{pages.map((p, i) => (
|
||||
<li key={p.n}>
|
||||
<button type="button" aria-current={i === sel ? "page" : undefined} onClick={() => setSel(i)}>
|
||||
<span className="mono">p.{p.n}</span>
|
||||
{p.dwg ? <span className="small muted"> {p.dwg}{p.rev ? ` R${p.rev}` : ""}</span> : null}
|
||||
{p.void ? <span className="pill st-bad">Void</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="pdfv-frame" ref={wrapRef}>
|
||||
{error ? <div className="callout warn flush">{error}</div> : null}
|
||||
{busy && !error ? <div className="small muted">Drawing the page</div> : null}
|
||||
<canvas ref={canvasRef} className="pdfv-canvas" role="img" aria-label={`Drawing page ${page.n}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||
import { callsTo, mockFetch } from "../test/fetchMock";
|
||||
import { makePacket } from "../test/packets";
|
||||
|
||||
function base(packet = makePacket()) {
|
||||
return {
|
||||
"/api/auth/me": { body: makeMe() },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/packets/1": { body: packet },
|
||||
"/api/packets/1/log": { body: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("PacketPage", () => {
|
||||
it("shows Send to warehouse disabled with the blocked reason", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/packets/1");
|
||||
const send = await screen.findByRole("button", { name: "Send to warehouse" });
|
||||
expect(send).toBeDisabled();
|
||||
expect(screen.getByText("Say whether this packet needs a heat map.")).toHaveClass("why");
|
||||
expect(screen.getByRole("heading", { name: "L999001 · WELD-PART" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to release");
|
||||
expect(screen.queryByText("View only.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables Send to warehouse when can includes it, and posts the transition with expect", async () => {
|
||||
const ready = makePacket({ heat_map: true, can: ["edit_header", "send_to_warehouse"], blocked: {} });
|
||||
const fetch = mockFetch({
|
||||
...base(ready),
|
||||
"POST /api/packets/1/transitions": { body: makePacket({ status: "warehouse", version: 4, can: [], blocked: {}, holder: "Warehouse" }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const send = await screen.findByRole("button", { name: "Send to warehouse" });
|
||||
expect(send).toBeEnabled();
|
||||
expect(screen.queryByText("Say whether this packet needs a heat map.")).not.toBeInTheDocument();
|
||||
fireEvent.click(send);
|
||||
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } }]);
|
||||
expect(screen.getByText("View only.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Send to warehouse" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("heat map chips PATCH with the base value", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base(),
|
||||
"PATCH /api/packets/1": { body: makePacket({ heat_map: true, version: 4 }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const group = await screen.findByRole("group", { name: "Heat map required?" });
|
||||
fireEvent.click(within(group).getByRole("button", { name: "Yes" }));
|
||||
await waitFor(() => expect(within(group).getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true"));
|
||||
expect(callsTo(fetch, "PATCH", "/api/packets/1")).toEqual([{ set: { heat_map: true }, base: { heat_map: null } }]);
|
||||
});
|
||||
|
||||
it("attaching a drawing uploads it, then signs and sends sign", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base(),
|
||||
"POST /api/assets": {
|
||||
status: 201,
|
||||
body: { id: "a1b2", mime: "application/pdf", size: 900, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/a1b2" },
|
||||
},
|
||||
"POST /api/packets/1/pages": { body: makePacket({ version: 4, drawing_state: "hand" }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const input = await screen.findByLabelText("Attach drawing (PDF)");
|
||||
const file = new File(["%PDF-1.4\n"], "d.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
||||
expect(within(sheet).getByText("Attach a drawing by hand")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText(/You are signing as/)).toHaveTextContent("You are signing as Clara Clerk.");
|
||||
expect(callsTo(fetch, "POST", "/api/assets")).toHaveLength(1);
|
||||
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-123" } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/pages")).toEqual([
|
||||
{ asset_id: "a1b2", type: "drawing", dwg: "CI-DWG-1", rev: "0", sign: { factor: "password", secret: "pw-123" } },
|
||||
]);
|
||||
expect(await screen.findByText("Drawing attached (1 page).")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a wrong signature keeps the sheet open with the attempts left", async () => {
|
||||
mockFetch({
|
||||
...base(),
|
||||
"POST /api/assets": { status: 201, body: { id: "a1", mime: "application/pdf", size: 9, sha256: "00", pages: 2, original_name: "d.pdf", url: "/_blob/a1" } },
|
||||
"POST /api/packets/1/pages": { status: 403, body: { detail: "Wrong password.", code: "wrong_secret", attempts_left: 2 } },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
fireEvent.change(await screen.findByLabelText("Attach drawing (PDF)"), { target: { files: [new File(["%PDF"], "d.pdf")] } });
|
||||
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "nope" } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
||||
expect(await within(sheet).findByText("Wrong password.")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("2 attempts left.")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Three wrong attempts end the session.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a stale step shows the server message and reloads the packet", async () => {
|
||||
let gets = 0;
|
||||
const fetch = mockFetch({
|
||||
...base(),
|
||||
"/api/packets/1": () => {
|
||||
gets++;
|
||||
return gets === 1
|
||||
? { body: makePacket({ heat_map: true, can: ["send_to_warehouse", "edit_header"], blocked: {} }) }
|
||||
: { body: makePacket({ status: "warehouse", heat_map: true, can: [], blocked: {}, holder: "Warehouse" }) };
|
||||
},
|
||||
"POST /api/packets/1/transitions": {
|
||||
status: 409,
|
||||
body: { detail: "Someone else already took this step.", code: "stale_step", current: { status: "warehouse", dept: "03", version: 5 } },
|
||||
},
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else already took this step.");
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
||||
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
|
||||
});
|
||||
|
||||
it("asks an admin for the IT reason and retries with it", async () => {
|
||||
const admin = makeMe({ roles: ["admin"], screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"] });
|
||||
let tries = 0;
|
||||
const fetch = mockFetch({
|
||||
...base(makePacket({ heat_map: true, can: ["send_to_warehouse"], blocked: {} })),
|
||||
"/api/auth/me": { body: admin },
|
||||
"POST /api/packets/1/transitions": () =>
|
||||
++tries === 1
|
||||
? { status: 400, body: { detail: "Give an IT reason.", code: "it_reason_required" } }
|
||||
: { body: makePacket({ status: "warehouse", can: [], blocked: {} }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "IT reason" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Why IT is making this change"), { target: { value: "Clerk is out sick today" } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Continue" }));
|
||||
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } },
|
||||
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" }, it_reason: "Clerk is out sick today" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows View only with the holder when the user has no actions", async () => {
|
||||
mockFetch({
|
||||
...base(makePacket({ can: [], blocked: {}, holder: "Clerical" })),
|
||||
"/api/auth/me": { body: makeMe({ roles: ["warehouse"], screens: ["warehouse"] }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const note = (await screen.findByText("View only.")).closest(".viewonly") as HTMLElement;
|
||||
expect(note).toHaveTextContent("This packet is with Clerical.");
|
||||
expect(screen.queryByRole("button", { name: /Send/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("group", { name: "Heat map required?" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reloads when a changed event names this packet", async () => {
|
||||
const sources: Array<{ emit: (t: string, d: unknown) => void }> = [];
|
||||
class FakeEventSource {
|
||||
listeners = new Map<string, (ev: MessageEvent) => void>();
|
||||
constructor() {
|
||||
sources.push(this);
|
||||
}
|
||||
addEventListener(type: string, fn: (ev: MessageEvent) => void) {
|
||||
this.listeners.set(type, fn);
|
||||
}
|
||||
close() {}
|
||||
emit(type: string, data: unknown) {
|
||||
this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) }));
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("EventSource", FakeEventSource);
|
||||
const fetch = mockFetch(base());
|
||||
renderApp("/packets/1");
|
||||
await screen.findByRole("heading", { name: "L999001 · WELD-PART" });
|
||||
expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(1);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 2, version: 9 }));
|
||||
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 1, version: 9 }));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { ApiError } from "../api/client";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
|
||||
import { homePath } from "../auth/roles";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { useGuard } from "../components/useGuard";
|
||||
import { useChanged } from "../lib/changes";
|
||||
import { errorText } from "../lib/format";
|
||||
import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets";
|
||||
import { DrawingViewer } from "./DrawingViewer";
|
||||
import { MoveSheet, SendToDeptSheet } from "./sheets";
|
||||
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
|
||||
|
||||
type Tab = "drawing" | "materials" | "workorders" | "docs" | "history";
|
||||
|
||||
/** The rule's signature meaning for attaching a drawing by hand. */
|
||||
const SIGN_ATTACH = "Attach a drawing by hand";
|
||||
const SIGN_FILE = "File in the data book";
|
||||
|
||||
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
|
||||
function Act({
|
||||
p,
|
||||
action,
|
||||
busy,
|
||||
onClick,
|
||||
className = "btn",
|
||||
children,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
action: string;
|
||||
busy: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const can = p.can.includes(action);
|
||||
const why = p.blocked[action];
|
||||
if (!can && !why) return null;
|
||||
const reason = why?.join(" ") ?? "";
|
||||
return (
|
||||
<div className="act">
|
||||
<button className={className} type="button" disabled={!can || busy} title={!can ? reason : undefined} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
{!can && reason ? <div className="why small">{reason}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */
|
||||
function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) {
|
||||
const head = p.can.includes("edit_header");
|
||||
const dwg = p.can.includes("edit_drawing_ref");
|
||||
const [v, setV] = useState({ title: p.title ?? "", customer: p.customer ?? "", wps: p.wps ?? "", drawing: p.drawing ?? "", rev: p.rev ?? "" });
|
||||
const field = (k: keyof typeof v, label: string, max: number, on: boolean) =>
|
||||
on ? (
|
||||
<label className="f">
|
||||
{label}
|
||||
<input className="in" value={v[k]} maxLength={max} onChange={(e) => setV((o) => ({ ...o, [k]: e.target.value }))} />
|
||||
</label>
|
||||
) : null;
|
||||
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const set: Partial<PacketHeader> = {};
|
||||
const base: Partial<PacketHeader> = {};
|
||||
for (const k of Object.keys(v) as Array<keyof typeof v>) {
|
||||
const now = v[k].trim() || null;
|
||||
const was = p[k] ?? null;
|
||||
if (now !== was) {
|
||||
set[k] = now;
|
||||
base[k] = was;
|
||||
}
|
||||
}
|
||||
if (Object.keys(set).length === 0) {
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
if (await mutate((g) => packetsApi.patch(p.id, { set, base, ...g }), { ok: "Saved" })) onDone();
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="panel stack" onSubmit={save} noValidate aria-label="Edit details">
|
||||
<div className="frow">
|
||||
{field("title", "Title", 200, head)}
|
||||
{field("customer", "Customer", 120, head)}
|
||||
{field("wps", "WPS", 40, head)}
|
||||
{field("drawing", "Drawing no.", 60, dwg)}
|
||||
{field("rev", "Rev", 10, dwg)}
|
||||
</div>
|
||||
<div className="sheet-acts start">
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Save
|
||||
</button>
|
||||
<button className="btn ghost" type="button" onClick={onDone}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** /packets/:id: the packet for every signed-in role. Controls come from `can` and `blocked`. */
|
||||
export function PacketPage() {
|
||||
const params = useParams();
|
||||
const id = Number(params.id);
|
||||
const navigate = useNavigate();
|
||||
const { me } = useAuth();
|
||||
const { run, askReason, sheet: guardSheet } = useGuard();
|
||||
const [packet, setPacket] = useState<PacketRead | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tab, setTab] = useState<Tab | null>(null);
|
||||
const [sheet, setSheet] = useState<"dept" | "move" | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const versionRef = useRef<number | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const p = await packetsApi.get(id);
|
||||
setPacket(p);
|
||||
setLoadError(null);
|
||||
} catch (err) {
|
||||
setLoadError(errorText(err));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
setPacket(null);
|
||||
setTab(null);
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
versionRef.current = packet?.version ?? null;
|
||||
}, [packet]);
|
||||
|
||||
// Live refresh: another screen changed this packet.
|
||||
useChanged(() => void reload(), {
|
||||
delayMs: 500,
|
||||
filter: (ev) => Number(ev.packet_id) === id && (ev.version === undefined || ev.version !== versionRef.current),
|
||||
});
|
||||
|
||||
const job = packet ? jobName(packet) : "";
|
||||
|
||||
const mutate: Mutate = useCallback(
|
||||
async (fn, opts = {}) => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await run(fn, { sign: opts.sign, sub: job });
|
||||
if (r === undefined) return false;
|
||||
if (r && typeof r === "object" && "id" in r) setPacket(r);
|
||||
else await reload();
|
||||
if (opts.ok) setMsg({ ok: true, text: opts.ok });
|
||||
return true;
|
||||
} catch (err) {
|
||||
// stale_step, field_conflict, row_conflict: someone else moved first. Show their state.
|
||||
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
|
||||
setMsg({ ok: false, text: errorText(err) });
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[run, reload, job],
|
||||
);
|
||||
|
||||
if (!packet) {
|
||||
return loadError ? <div className="callout warn">{loadError}</div> : <div className="empty slim">Loading</div>;
|
||||
}
|
||||
|
||||
const p = packet;
|
||||
const can = (a: string) => p.can.includes(a);
|
||||
const draft = p.status === "draft";
|
||||
const transition = (action: string, extra: Omit<TransitionBody, "action" | "expect"> = {}, ok?: string) =>
|
||||
mutate((g) => packetsApi.transition(p.id, { action, expect: { status: p.status, dept: p.dept.code }, ...extra, ...g }), { ok });
|
||||
|
||||
const setHeatMap = (v: boolean) =>
|
||||
mutate((g) => packetsApi.patch(p.id, { set: { heat_map: v }, base: { heat_map: p.heat_map }, ...g }));
|
||||
|
||||
const attach = async (file: File) => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
let asset;
|
||||
try {
|
||||
asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "drawing" });
|
||||
} catch (err) {
|
||||
setMsg({ ok: false, text: errorText(err) });
|
||||
setBusy(false);
|
||||
return;
|
||||
} finally {
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
setBusy(false);
|
||||
if (asset.mime !== "application/pdf") {
|
||||
setMsg({ ok: false, text: "The drawing must be a PDF." });
|
||||
return;
|
||||
}
|
||||
const pages = asset.pages ?? 0;
|
||||
await mutate(
|
||||
(g) =>
|
||||
packetsApi.attachPages(p.id, {
|
||||
asset_id: asset.id,
|
||||
type: "drawing",
|
||||
...(p.drawing ? { dwg: p.drawing } : {}),
|
||||
...(p.rev ? { rev: p.rev } : {}),
|
||||
...g,
|
||||
}),
|
||||
{ sign: SIGN_ATTACH, ok: pages ? `Drawing attached (${pages} page${pages === 1 ? "" : "s"}).` : "Drawing attached." },
|
||||
);
|
||||
};
|
||||
|
||||
const sendToQc = async () => {
|
||||
const note = await askReason({
|
||||
title: "Send straight to Q.C.",
|
||||
sub: job,
|
||||
label: "Note (optional, Q.C. will see it)",
|
||||
note: "Skips the warehouse and the shop. Q.C. gets it for inspection.",
|
||||
min: 0,
|
||||
submitLabel: "Send to Q.C.",
|
||||
});
|
||||
if (note === null) return;
|
||||
await transition("send_draft_to_qc", note ? { note } : {}, "Sent to Q.C.");
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
const reason = await askReason({
|
||||
title: "Remove packet",
|
||||
sub: job,
|
||||
note: "The packet leaves every board. It stays in the record with your reason.",
|
||||
min: 8,
|
||||
danger: true,
|
||||
submitLabel: "Remove packet",
|
||||
});
|
||||
if (reason === null) return;
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await run((g) => packetsApi.remove(p.id, { reason, ...g }), { sub: job });
|
||||
if (r !== undefined) navigate(`/clerical/units/${p.unit.id}`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) await reload();
|
||||
setMsg({ ok: false, text: errorText(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDrawing = async (n: number) => {
|
||||
await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." });
|
||||
};
|
||||
|
||||
const fileIt = () => mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), { sign: SIGN_FILE, ok: "Filed in the data book." });
|
||||
|
||||
const drawingPages = p.pages.filter((x) => x.type === "drawing");
|
||||
const shownTab: Tab = tab ?? (drawingPages.length ? "drawing" : "materials");
|
||||
// The warehouse short-parts exception and the data book date are not edits of the packet itself.
|
||||
const passive = new Set(["wh_receive_short", "set_databook_at"]);
|
||||
const viewOnly =
|
||||
p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a));
|
||||
const project = p.unit.project;
|
||||
const di = dueInfo(p.unit.due_effective, project.status === "closed");
|
||||
const target = targetHours(p);
|
||||
const lotQty = Math.max(0, ...p.work_orders.map((w) => Number(w.qty) || 0));
|
||||
const backTo = me?.screens.includes("clerical") ? `/clerical/units/${p.unit.id}` : homePath(me?.screens ?? []);
|
||||
const hasDraftControls =
|
||||
draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]);
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string; count?: number }> = [
|
||||
{ id: "drawing", label: "Drawing", count: drawingPages.length },
|
||||
{ id: "materials", label: "Materials", count: p.bom.length },
|
||||
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
|
||||
{ id: "docs", label: "Documents", count: p.docs.length },
|
||||
{ id: "history", label: "History" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className="back" to={backTo}>
|
||||
← Back
|
||||
</Link>
|
||||
<div className="dhead">
|
||||
<div className="grow0">
|
||||
<div className="eyebrow">
|
||||
{p.op ?? ""} · {statusLabel(p.status)}
|
||||
</div>
|
||||
<h1>{job}</h1>
|
||||
{p.title ? <div className="dtitle">{p.title}</div> : null}
|
||||
<div className="small crumbs2">
|
||||
Project <b>{project.number}</b> {project.name ?? ""} <span className="muted">›</span> Unit <b>{p.unit.number}</b> {p.unit.name ?? ""}
|
||||
{project.po ? (
|
||||
<>
|
||||
{" "}
|
||||
· PO <span className="mono">{project.po}</span>
|
||||
</>
|
||||
) : null}{" "}
|
||||
· <span className={di.cls}>{di.txt}</span>
|
||||
</div>
|
||||
<div className="facts">
|
||||
{lotQty ? (
|
||||
<div>
|
||||
<span>Lot qty </span>
|
||||
<b>{lotQty}</b>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<span>Customer </span>
|
||||
<b>{p.customer || "-"}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Drawing </span>
|
||||
<b>{p.drawing ? `${p.drawing} R${p.rev || "-"}` : "-"}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>WPS </span>
|
||||
<b>{p.wps || "-"}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Department </span>
|
||||
<b>
|
||||
{p.dept.code} {p.dept.name}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Target </span>
|
||||
<b>{target.toFixed(1)} h</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Heat map </span>
|
||||
<b>{p.heat_map === true ? "Yes" : p.heat_map === false ? "Not required" : "Not answered"}</b>
|
||||
</div>
|
||||
{p.for_sup ? (
|
||||
<div>
|
||||
<span>For </span>
|
||||
<b>{p.for_sup.display_name}</b>
|
||||
</div>
|
||||
) : null}
|
||||
{p.station ? (
|
||||
<div>
|
||||
<span>Station </span>
|
||||
<b>{p.station.name}</b>
|
||||
</div>
|
||||
) : null}
|
||||
{p.welder ? (
|
||||
<div>
|
||||
<span>Welder </span>
|
||||
<b>
|
||||
{p.welder.display_name}
|
||||
{p.welder.stamp ? ` [${p.welder.stamp}]` : ""}
|
||||
</b>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pacts">
|
||||
<span className={pillClass(p.status)} data-testid="status-pill">
|
||||
{statusLabel(p.status)}
|
||||
</span>
|
||||
{can("edit_header") || can("edit_drawing_ref") ? (
|
||||
<button className="btn sm" type="button" onClick={() => setEditing((e) => !e)} aria-expanded={editing}>
|
||||
Edit details
|
||||
</button>
|
||||
) : null}
|
||||
{can("move_unit") ? (
|
||||
<button className="btn sm" type="button" disabled={busy} onClick={() => setSheet("move")}>
|
||||
Move
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.flag ? <div className={`small flagline ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
||||
{p.removed ? (
|
||||
<div className="callout warn">
|
||||
<b>Removed</b>
|
||||
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
|
||||
</div>
|
||||
) : null}
|
||||
{p.return_note ? (
|
||||
<div className="callout warn">
|
||||
<b>Returned:</b> {p.return_note}
|
||||
</div>
|
||||
) : null}
|
||||
{viewOnly && !p.removed ? (
|
||||
<div className="viewonly">
|
||||
<span aria-hidden="true">●</span>
|
||||
<div>
|
||||
<b>View only.</b> This packet is with <b>{p.holder}</b>. You can follow its progress. Changes open up only when it is sent to you or sent back.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{msg ? (
|
||||
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editing ? <HeaderEdit key={p.version} p={p} mutate={mutate} busy={busy} onDone={() => setEditing(false)} /> : null}
|
||||
|
||||
{hasDraftControls ? (
|
||||
<section className="panel actbox" aria-label="Draft steps">
|
||||
<div>
|
||||
<b>Ready to release.</b> Check the BOM and work orders, attach the drawing, and say whether it needs a heat map. Then send it to the warehouse for the
|
||||
parts pull, or straight to a department or Q.C.
|
||||
</div>
|
||||
{can("edit_header") ? (
|
||||
<div className="hmq" role="group" aria-label="Heat map required?">
|
||||
<b>Heat map required?</b>
|
||||
<button type="button" className="chip sm" aria-pressed={p.heat_map === true} disabled={busy} onClick={() => void setHeatMap(true)}>
|
||||
Yes
|
||||
</button>
|
||||
<button type="button" className="chip sm" aria-pressed={p.heat_map === false} disabled={busy} onClick={() => void setHeatMap(false)}>
|
||||
No
|
||||
</button>
|
||||
{p.heat_map === null ? <span className="due soon small">choose before sending</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="acts">
|
||||
{can("edit_document_set") ? (
|
||||
<div className="act">
|
||||
<label className={busy ? "btn dim" : "btn"}>
|
||||
{drawingPages.length ? "Attach another drawing" : "Attach drawing"}
|
||||
<input
|
||||
ref={fileRef}
|
||||
className="sr"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
aria-label="Attach drawing (PDF)"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void attach(f);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<Act p={p} action="send_to_warehouse" busy={busy} className="btn pri" onClick={() => void transition("send_to_warehouse", {}, "Sent to the warehouse.")}>
|
||||
Send to warehouse
|
||||
</Act>
|
||||
<Act p={p} action="release_to_shop" busy={busy} onClick={() => setSheet("dept")}>
|
||||
Send to a department
|
||||
</Act>
|
||||
<Act p={p} action="send_draft_to_qc" busy={busy} onClick={() => void sendToQc()}>
|
||||
Send to Q.C.
|
||||
</Act>
|
||||
<Act p={p} action="remove" busy={busy} className="btn rejb" onClick={() => void remove()}>
|
||||
Remove
|
||||
</Act>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? (
|
||||
<section className="panel actbox" aria-label="Close and file">
|
||||
<div>
|
||||
{p.vclosed ? (
|
||||
<>
|
||||
<b>Ready for the data book.</b> Job closed in VISUAL by {p.vclosed.by?.display_name ?? "Clerical"}. Mark it filed when the data book is done.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="acts">
|
||||
{!p.vclosed ? (
|
||||
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
|
||||
Job closed in VISUAL
|
||||
</Act>
|
||||
) : (
|
||||
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
|
||||
Not closed in VISUAL, move back
|
||||
</Act>
|
||||
)}
|
||||
<Act p={p} action="file" busy={busy} onClick={() => void fileIt()}>
|
||||
Mark filed
|
||||
</Act>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="tabs" role="tablist" aria-label="Packet">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`ptab-${t.id}`}
|
||||
aria-selected={shownTab === t.id}
|
||||
aria-controls={`ppanel-${t.id}`}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
{t.count !== undefined ? <span className="cnt">{t.count}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
|
||||
{shownTab === "drawing" ? (
|
||||
<div className="stack">
|
||||
{draft && can("edit_document_set") && drawingPages.length ? (
|
||||
<div className="sec-h flush">
|
||||
<span className="small muted">
|
||||
{drawingPages.length} drawing page{drawingPages.length === 1 ? "" : "s"} attached by hand.
|
||||
</span>
|
||||
<span className="grow" />
|
||||
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => void removeDrawing(drawingPages[0]?.n ?? 1)}>
|
||||
Remove drawing
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<DrawingViewer pages={drawingPages} />
|
||||
</div>
|
||||
) : null}
|
||||
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} /> : null}
|
||||
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
|
||||
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
|
||||
{shownTab === "history" ? <HistoryTab p={p} /> : null}
|
||||
</div>
|
||||
|
||||
{sheet === "dept" ? (
|
||||
<SendToDeptSheet
|
||||
p={p}
|
||||
sub={job}
|
||||
onCancel={() => setSheet(null)}
|
||||
onSend={(dept, note) => {
|
||||
setSheet(null);
|
||||
void transition("release_to_shop", { dept, ...(note ? { note } : {}) }, "Sent to the department.");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{sheet === "move" ? (
|
||||
<MoveSheet
|
||||
p={p}
|
||||
sub={job}
|
||||
onCancel={() => setSheet(null)}
|
||||
onMove={(unitId, reason) => {
|
||||
setSheet(null);
|
||||
void mutate((g) => packetsApi.move(p.id, { unit_id: unitId, ...(reason ? { reason } : {}), ...g }), { ok: "Packet moved." });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{guardSheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { PacketRead } from "../api/types";
|
||||
import { PickUnit, type UnitPick } from "../components/PickUnit";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { useShopDepartments } from "../lib/useDepartments";
|
||||
|
||||
/** Clerical: send a draft straight to a shop department. Skips the warehouse parts pull. */
|
||||
export function SendToDeptSheet({
|
||||
p,
|
||||
sub,
|
||||
onSend,
|
||||
onCancel,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
sub: string;
|
||||
onSend: (dept: string, note: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const depts = useShopDepartments();
|
||||
const [dept, setDept] = useState(p.dept.code);
|
||||
const [note, setNote] = useState("");
|
||||
const value = depts.some((d) => d.code === dept) ? dept : (depts[0]?.code ?? "");
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (value) onSend(value, note.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet title="Send straight to a department" sub={sub} onClose={onCancel}>
|
||||
<div className="small">Skips the warehouse parts pull. The department's lead or supervisor gets it ready to assign.</div>
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<label className="f">
|
||||
Department
|
||||
<select className="in" value={value} onChange={(e) => setDept(e.target.value)}>
|
||||
{depts.map((d) => (
|
||||
<option key={d.code} value={d.code}>
|
||||
{d.code} {d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Note (optional, they will see it)
|
||||
<input className="in" value={note} maxLength={500} placeholder="e.g. parts already staged at the station" onChange={(e) => setNote(e.target.value)} />
|
||||
</label>
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={!value}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** Clerical: move a packet to another unit, through the two-step picker. */
|
||||
export function MoveSheet({
|
||||
p,
|
||||
sub,
|
||||
onMove,
|
||||
onCancel,
|
||||
}: {
|
||||
p: PacketRead;
|
||||
sub: string;
|
||||
onMove: (unitId: number, reason: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [pick, setPick] = useState<UnitPick>({
|
||||
project: { id: p.unit.project.id, number: p.unit.project.number, name: p.unit.project.name, customer: p.unit.project.customer, po: p.unit.project.po, status: p.unit.project.status },
|
||||
unit: null,
|
||||
});
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!pick.unit) {
|
||||
setError("Pick the unit to move it to.");
|
||||
return;
|
||||
}
|
||||
if (pick.unit.id === p.unit.id) {
|
||||
setError("The packet is in that unit already.");
|
||||
return;
|
||||
}
|
||||
onMove(pick.unit.id, reason.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet title="Move to another unit" sub={sub} onClose={onCancel}>
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<PickUnit value={pick} onChange={setPick} requireUnit />
|
||||
<label className="f">
|
||||
Reason (optional)
|
||||
<input className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<div className="small muted">The move is recorded in the packet history and the access log.</div>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit">
|
||||
Move packet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import QRCode from "qrcode";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { packetsApi } from "../api/packets";
|
||||
import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types";
|
||||
import { errorText, fmtTime } from "../lib/format";
|
||||
import { useLoad } from "../lib/useLoad";
|
||||
|
||||
export type Mutate = (
|
||||
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
|
||||
opts?: { sign?: string | undefined; ok?: string | undefined },
|
||||
) => Promise<boolean>;
|
||||
|
||||
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "-" : String(v));
|
||||
|
||||
/* ---------- Materials ---------- */
|
||||
|
||||
interface LineDraft {
|
||||
qty: string;
|
||||
part_id: string;
|
||||
description: string;
|
||||
material: string;
|
||||
length: string;
|
||||
dwg_ref: string;
|
||||
}
|
||||
|
||||
const toDraft = (b: BomLine): LineDraft => ({
|
||||
qty: b.qty === null || b.qty === undefined ? "" : String(b.qty),
|
||||
part_id: b.part_id ?? "",
|
||||
description: b.description ?? "",
|
||||
material: b.material ?? "",
|
||||
length: b.length ?? "",
|
||||
dwg_ref: b.dwg_ref ?? "",
|
||||
});
|
||||
|
||||
const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
|
||||
|
||||
/** The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). */
|
||||
export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
|
||||
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
|
||||
const [lines, setLines] = useState<LineDraft[] | null>(null);
|
||||
const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
||||
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!lines) return;
|
||||
const bom: BomLine[] = lines
|
||||
.filter((l) => l.part_id.trim() || l.description.trim())
|
||||
.map((l, i) => ({
|
||||
item: i + 1,
|
||||
qty: l.qty.trim() === "" ? null : Number(l.qty),
|
||||
part_id: l.part_id.trim() || null,
|
||||
description: l.description.trim() || null,
|
||||
material: l.material.trim() || null,
|
||||
length: l.length.trim() || null,
|
||||
dwg_ref: l.dwg_ref.trim() || null,
|
||||
}));
|
||||
const ok = await mutate((g) => packetsApi.putBom(p.id, p.version, bom, g), { ok: "BOM saved" });
|
||||
if (ok) setLines(null);
|
||||
};
|
||||
|
||||
if (lines) {
|
||||
return (
|
||||
<form className="stack" onSubmit={save} noValidate>
|
||||
<div className="tw">
|
||||
<table className="edit">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th className="num">Qty</th>
|
||||
<th>Part ID</th>
|
||||
<th>Description</th>
|
||||
<th>Material</th>
|
||||
<th>Length</th>
|
||||
<th>Dwg ref</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((l, i) => (
|
||||
<tr key={i}>
|
||||
<td className="mono">{i + 1}</td>
|
||||
<td>
|
||||
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={l.qty} onChange={(e) => set(i, "qty", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} maxLength={60} value={l.part_id} onChange={(e) => set(i, "part_id", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm" aria-label={`Line ${i + 1} description`} maxLength={240} value={l.description} onChange={(e) => set(i, "description", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm" aria-label={`Line ${i + 1} material`} maxLength={120} value={l.material} onChange={(e) => set(i, "material", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm" aria-label={`Line ${i + 1} length`} maxLength={40} value={l.length} onChange={(e) => set(i, "length", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} maxLength={60} value={l.dwg_ref} onChange={(e) => set(i, "dwg_ref", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setLines((rows) => (rows ?? []).filter((_, j) => j !== i))}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sheet-acts start">
|
||||
<button className="btn sm" type="button" onClick={() => setLines((rows) => [...(rows ?? []), { ...EMPTY_LINE }])}>
|
||||
Add line
|
||||
</button>
|
||||
<span className="grow" />
|
||||
<button className="btn ghost" type="button" onClick={() => setLines(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Save BOM
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{editable ? (
|
||||
<div className="sec-h flush">
|
||||
<span className="small muted">This packet was made by hand. Clerical can change its lines while it is a draft.</span>
|
||||
<span className="grow" />
|
||||
<button className="btn sm" type="button" onClick={() => setLines(p.bom.length ? p.bom.map(toDraft) : [{ ...EMPTY_LINE }])}>
|
||||
Edit lines
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{p.bom.length ? (
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th className="num">Qty</th>
|
||||
<th>Part ID</th>
|
||||
<th>Description</th>
|
||||
<th>Material</th>
|
||||
<th>Length</th>
|
||||
<th>Heat</th>
|
||||
<th>Pulled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.bom.map((b) => (
|
||||
<tr key={b.id ?? b.item} className={b.na ? "muted" : undefined}>
|
||||
<td>
|
||||
<span className="balloon">{b.item}</span>
|
||||
</td>
|
||||
<td className="num">{show(b.qty)}</td>
|
||||
<td className="mono small">{show(b.part_id)}</td>
|
||||
<td>
|
||||
{show(b.description)}
|
||||
{b.dwg_ref ? <div className="sub">Dwg ref {b.dwg_ref}</div> : null}
|
||||
</td>
|
||||
<td className="small">{show(b.material)}</td>
|
||||
<td className="small">{show(b.length)}</td>
|
||||
<td className="mono small">{show(b.heat)}</td>
|
||||
<td>
|
||||
{b.na ? (
|
||||
<span className="pill st-filed">N/A</span>
|
||||
) : b.short ? (
|
||||
<span className="pill st-review">{String(b.short.qty_open)} short</span>
|
||||
) : b.pulled ? (
|
||||
<span className="pill st-ok">Pulled</span>
|
||||
) : (
|
||||
<span className="muted small">Not yet</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty">No BOM lines on this packet.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Work orders ---------- */
|
||||
|
||||
function woKey(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub">): string {
|
||||
return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
|
||||
}
|
||||
|
||||
function Qr({ text }: { text: string }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
const c = ref.current;
|
||||
if (!c) return;
|
||||
let ok = true;
|
||||
try {
|
||||
QRCode.toCanvas(c, text, { width: 128, margin: 1, errorCorrectionLevel: "M" }).catch(() => {
|
||||
if (ok) setFailed(true);
|
||||
});
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
return () => {
|
||||
ok = false;
|
||||
};
|
||||
}, [text]);
|
||||
return (
|
||||
<div className="code">
|
||||
{failed ? <span className="small muted">No QR</span> : <canvas ref={ref} role="img" aria-label={`QR code ${text}`} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The work orders, and one QR code per work order key. */
|
||||
export function WorkOrdersTab({ p }: { p: PacketRead }) {
|
||||
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>WO</th>
|
||||
<th>Lot</th>
|
||||
<th>Split</th>
|
||||
<th>Sub</th>
|
||||
<th>Seq</th>
|
||||
<th>Operation</th>
|
||||
<th className="num">Qty</th>
|
||||
<th className="num">Target hrs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.work_orders.map((w) => (
|
||||
<tr key={w.id ?? woKey(w)} className={w.gone_at ? "muted" : undefined}>
|
||||
<td className="mono">{w.wo_base}</td>
|
||||
<td className="mono">{w.lot}</td>
|
||||
<td className="mono">{w.split}</td>
|
||||
<td className="mono">{w.sub}</td>
|
||||
<td className="mono">{show(w.seq)}</td>
|
||||
<td>{show(w.op)}</td>
|
||||
<td className="num">{show(w.qty)}</td>
|
||||
<td className="num">{show(w.target_hrs)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="qrs">
|
||||
{p.work_orders.map((w) => {
|
||||
const k = woKey(w);
|
||||
return (
|
||||
<div key={w.id ?? k} className="qr">
|
||||
<Qr text={k} />
|
||||
<div>
|
||||
<div className="wo">{w.wo_base}</div>
|
||||
<div className="mono small">{k}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Documents ---------- */
|
||||
|
||||
const DOC_KINDS: ReadonlyArray<[string, string]> = [
|
||||
["mtr", "Material test report"],
|
||||
["nde", "NDE report"],
|
||||
["procedure", "Procedure (WPS / PQR)"],
|
||||
["photo", "Photo"],
|
||||
["other", "Other"],
|
||||
];
|
||||
|
||||
const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k;
|
||||
|
||||
/** Documents attached to the packet. Clerical and Q.C. add and remove them. */
|
||||
export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
|
||||
const canAdd = p.can.includes("doc_add");
|
||||
const canRemove = p.can.includes("doc_remove");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [kind, setKind] = useState("mtr");
|
||||
const [title, setTitle] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [confirm, setConfirm] = useState<number | null>(null);
|
||||
|
||||
const add = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!file) {
|
||||
setError("Choose a file.");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "doc" });
|
||||
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" });
|
||||
if (ok) {
|
||||
setTitle("");
|
||||
setFile(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{p.docs.length ? (
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Kind</th>
|
||||
<th>Added</th>
|
||||
<th>By</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.docs.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>
|
||||
<a className="lnk" href={d.url} target="_blank" rel="noopener noreferrer">
|
||||
{d.title || "Untitled"}
|
||||
</a>
|
||||
</td>
|
||||
<td className="small">{kindLabel(d.kind)}</td>
|
||||
<td className="small mono">{fmtTime(d.added_at)}</td>
|
||||
<td className="small">{d.added_by?.display_name ?? "-"}</td>
|
||||
<td className="nowrap">
|
||||
{canRemove ? (
|
||||
confirm === d.id ? (
|
||||
<>
|
||||
<button
|
||||
className="btn sm rejb"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void mutate((g) => packetsApi.removeDoc(p.id, d.id, g), { ok: "Document removed" }).then(() => setConfirm(null))}
|
||||
>
|
||||
Confirm remove
|
||||
</button>{" "}
|
||||
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
|
||||
Remove
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty slim">No documents on this packet.</div>
|
||||
)}
|
||||
{canAdd ? (
|
||||
<form className="panel stack" onSubmit={add} noValidate aria-labelledby="docadd-h">
|
||||
<h3 id="docadd-h">Add a document</h3>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
File (PDF, PNG or JPEG)
|
||||
<input
|
||||
ref={fileRef}
|
||||
className="in"
|
||||
type="file"
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="f">
|
||||
Kind
|
||||
<select className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
{DOC_KINDS.map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Title
|
||||
<input className="in" value={title} maxLength={160} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<button className="btn pri" type="submit" disabled={busy || uploading}>
|
||||
{uploading ? "Uploading" : "Add document"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- History ---------- */
|
||||
|
||||
/** GET /log, newest first. IT edits and signatures are marked. */
|
||||
export function HistoryTab({ p }: { p: PacketRead }) {
|
||||
const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`);
|
||||
if (load.state === "loading") return <div className="empty slim">Loading</div>;
|
||||
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
|
||||
const rows = load.data;
|
||||
if (!rows.length) return <div className="empty slim">Nothing recorded yet.</div>;
|
||||
return (
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Who</th>
|
||||
<th>What</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="small mono nowrap">{fmtTime(r.at)}</td>
|
||||
<td className="small">
|
||||
{r.by?.display_name ?? "System"}
|
||||
{r.role ? <div className="sub">{r.role}</div> : null}
|
||||
</td>
|
||||
<td>
|
||||
{r.action}
|
||||
{r.signed ? <span className="pill st-ok">Signed</span> : null}
|
||||
{r.it_reason ? <span className="pill st-warehouse">IT edit</span> : null}
|
||||
</td>
|
||||
<td className="small">
|
||||
{r.note ?? ""}
|
||||
{r.it_reason ? <div className="sub">IT reason: {r.it_reason}</div> : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate, useParams } from "react-router";
|
||||
import { homePath, isScreen, orderScreens } from "../auth/roles";
|
||||
import { homePath, isScreen, orderScreens, type Screen } from "../auth/roles";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { AdminPage } from "./admin/AdminPage";
|
||||
import { ClericalHome } from "./clerical/ClericalHome";
|
||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||
|
||||
/** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */
|
||||
export function RequireScreen({ screen, children }: { screen: Screen; children: ReactNode }) {
|
||||
const { me } = useAuth();
|
||||
if (!me) return null;
|
||||
if (!orderScreens(me.screens).includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** /:screen. An unknown screen, or one the user does not have, goes to the user's first screen. */
|
||||
export function ScreenRoute() {
|
||||
const { screen } = useParams();
|
||||
@@ -12,5 +22,6 @@ export function ScreenRoute() {
|
||||
const allowed = orderScreens(me.screens);
|
||||
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
|
||||
if (screen === "admin") return <AdminPage />;
|
||||
if (screen === "clerical") return <ClericalHome />;
|
||||
return <PlaceholderScreen key={screen} screen={screen} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||
import { callsTo, mockFetch } from "../../test/fetchMock";
|
||||
import { makeCard, makeProject, makeUnit } from "../../test/packets";
|
||||
|
||||
const CARDS = [
|
||||
makeCard({ id: 1, flag: "Made by hand - check it and send it on" }),
|
||||
makeCard({ id: 2, unit: { id: 12, number: "L999002" }, op: "FIT-UP" }),
|
||||
makeCard({ id: 3, status: "warehouse", holder: "Warehouse" }),
|
||||
makeCard({ id: 4, status: "in_progress" }),
|
||||
makeCard({ id: 5, status: "complete", routed_to: "databook", unit: { id: 13, number: "L999003" } }),
|
||||
makeCard({ id: 6, status: "complete", routed_to: "databook", vclosed: { at: "2026-10-15T10:00:00Z", by: null }, unit: { id: 14, number: "L999004" } }),
|
||||
makeCard({ id: 7, status: "filed", unit: { id: 15, number: "L999005" } }),
|
||||
];
|
||||
|
||||
function base() {
|
||||
return {
|
||||
"/api/auth/me": { body: makeMe() },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
"/api/packets": { body: { packets: CARDS } },
|
||||
"/api/projects": { body: [makeProject()] },
|
||||
};
|
||||
}
|
||||
|
||||
function column(title: string): HTMLElement {
|
||||
return screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
|
||||
}
|
||||
|
||||
describe("ClericalHome", () => {
|
||||
it("renders the boards and the numbers from the list answer", async () => {
|
||||
mockFetch(base());
|
||||
renderApp("/clerical");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
await screen.findByRole("heading", { name: /^Flagged/ });
|
||||
|
||||
expect(screen.getByLabelText("Flagged: 1")).toHaveClass("kpi", "hot");
|
||||
expect(screen.getByLabelText("With warehouse: 1")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("In the shop: 1")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Ready to close: 1")).toHaveClass("hot");
|
||||
expect(screen.getByLabelText("Changes to approve: 0")).toHaveClass("zero");
|
||||
|
||||
expect(within(column("Changes to approve")).getByText("Arrives in phase 3 with the VISUAL feed.")).toBeInTheDocument();
|
||||
const flagged = column("Flagged");
|
||||
expect(within(flagged).getByText("L999001 · WELD-PART")).toBeInTheDocument();
|
||||
expect(within(flagged).getByText(/Made by hand/)).toBeInTheDocument();
|
||||
expect(within(column("Going to the warehouse")).getByText("L999002 · FIT-UP")).toBeInTheDocument();
|
||||
expect(within(column("Ready to close")).getByText("L999003 · WELD-PART")).toBeInTheDocument();
|
||||
expect(within(column("Ready for data book")).getByText("L999004 · WELD-PART")).toBeInTheDocument();
|
||||
const filed = column("Filed");
|
||||
expect(within(filed).getByText("L999005 · WELD-PART")).toBeInTheDocument();
|
||||
expect(within(filed).getByText("Filed in data book")).toHaveClass("pill", "st-filed");
|
||||
|
||||
// The list view holds the same packets.
|
||||
fireEvent.click(screen.getByRole("button", { name: "List" }));
|
||||
const table = await screen.findByRole("table");
|
||||
expect(within(table).getAllByRole("row")).toHaveLength(1 + 5);
|
||||
});
|
||||
|
||||
it("the picker loads the units after a project is chosen", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base(),
|
||||
"/api/units": { body: [makeUnit(), makeUnit({ id: 12, number: "L999002", lot: "002" })] },
|
||||
});
|
||||
renderApp("/clerical");
|
||||
const bar = await screen.findByRole("region", { name: "Find jobs" });
|
||||
expect(callsTo(fetch, "GET", "/api/units")).toHaveLength(0);
|
||||
fireEvent.click(await within(bar).findByRole("button", { name: /L999/ }));
|
||||
|
||||
const unit = await within(bar).findByLabelText("Unit");
|
||||
await within(unit).findByRole("option", { name: "L999002" });
|
||||
const urls = fetch.mock.calls.map(([u]) => String(u));
|
||||
expect(urls).toContain("/api/units?project_id=7");
|
||||
// Choosing the project narrows the boards.
|
||||
await waitFor(() => expect(urls.concat(fetch.mock.calls.map(([u]) => String(u)))).toContain("/api/packets?limit=500&project_id=7"));
|
||||
|
||||
fireEvent.change(unit, { target: { value: "12" } });
|
||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12"));
|
||||
});
|
||||
|
||||
it("the new project sheet posts and then opens Add units", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base(),
|
||||
"POST /api/projects": { status: 201, body: makeProject({ id: 9, number: "L998", name: "New one", units: 0 }) },
|
||||
"POST /api/projects/9/units": { status: 201, body: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] },
|
||||
"/api/projects/9": { body: makeProject({ id: 9, number: "L998", name: "New one", units: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] }) },
|
||||
});
|
||||
renderApp("/clerical");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "New project" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "New project" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Project number"), { target: { value: "l998" } });
|
||||
fireEvent.change(within(sheet).getByLabelText("Project name"), { target: { value: "New one" } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Create project" }));
|
||||
|
||||
const add = await screen.findByRole("dialog", { name: "Add units" });
|
||||
expect(callsTo(fetch, "POST", "/api/projects")).toEqual([{ number: "L998", name: "New one", customer: "", po: "", due: null, notes: "" }]);
|
||||
expect(within(add).getByLabelText("Lot")).toHaveValue("001");
|
||||
|
||||
// Several units: a preview of the numbers.
|
||||
fireEvent.click(within(add).getByRole("button", { name: "Several units" }));
|
||||
fireEvent.change(within(add).getByLabelText("How many"), { target: { value: "3" } });
|
||||
expect(within(add).getByText("Numbers: L998001, L998002, L998003")).toBeInTheDocument();
|
||||
fireEvent.click(within(add).getByRole("button", { name: "Add" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "L998 · New one" })).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/projects/9/units")).toEqual([{ lots: ["001", "002", "003"], name: "", due: null, notes: "" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { packetsApi } from "../../api/packets";
|
||||
import type { PacketCard } from "../../api/types";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { Board } from "../../components/Board";
|
||||
import { FindBar, type BoardView } from "../../components/FindBar";
|
||||
import { Kpis } from "../../components/Kpis";
|
||||
import type { UnitPick } from "../../components/PickUnit";
|
||||
import { canEditProjects } from "../../lib/access";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { fmtTime } from "../../lib/format";
|
||||
import { STATUSES } from "../../api/types";
|
||||
import { dueInfo, flagClass, jobName, pillClass, statusLabel } from "../../lib/packets";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { byTime, clericalBoards } from "./boards";
|
||||
import { ProjectsSection } from "./ProjectsSection";
|
||||
import { NewPacketSheet } from "./sheets";
|
||||
|
||||
type SortKey = "updated" | "unit" | "due" | "status" | "dept";
|
||||
|
||||
const SORTS: ReadonlyArray<[SortKey, string]> = [
|
||||
["updated", "Last updated"],
|
||||
["unit", "Unit"],
|
||||
["due", "Due date"],
|
||||
["status", "Status"],
|
||||
["dept", "Department"],
|
||||
];
|
||||
|
||||
function sorter(k: SortKey): (a: PacketCard, b: PacketCard) => number {
|
||||
switch (k) {
|
||||
case "unit":
|
||||
return (a, b) => a.unit.number.localeCompare(b.unit.number, undefined, { numeric: true }) || (a.op ?? "").localeCompare(b.op ?? "");
|
||||
case "due":
|
||||
return (a, b) => (a.due_effective ?? "9999").localeCompare(b.due_effective ?? "9999");
|
||||
case "status":
|
||||
return (a, b) => STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status);
|
||||
case "dept":
|
||||
return (a, b) => a.dept.code.localeCompare(b.dept.code);
|
||||
default:
|
||||
return (a, b) => byTime(b.updated_at, a.updated_at);
|
||||
}
|
||||
}
|
||||
|
||||
function JobList({ rows, sort, onSort }: { rows: PacketCard[]; sort: SortKey; onSort: (k: SortKey) => void }) {
|
||||
const navigate = useNavigate();
|
||||
const sorted = useMemo(() => [...rows].sort(sorter(sort)), [rows, sort]);
|
||||
const onKey = (e: KeyboardEvent, id: number) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/packets/${id}`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="sec" aria-labelledby="jobs-h">
|
||||
<div className="sec-h">
|
||||
<h2 id="jobs-h">Jobs</h2>
|
||||
<span className="ct">
|
||||
{sorted.length} packet{sorted.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span className="grow" />
|
||||
<label className="f">
|
||||
Sort by
|
||||
<select className="in" value={sort} onChange={(e) => onSort(e.target.value as SortKey)}>
|
||||
{SORTS.map(([k, l]) => (
|
||||
<option key={k} value={k}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="tw">
|
||||
<table className="joblist">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Operation</th>
|
||||
<th>Department</th>
|
||||
<th>Status</th>
|
||||
<th>Drawing</th>
|
||||
<th>Due</th>
|
||||
<th>Updated</th>
|
||||
<th>Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((p) => {
|
||||
const di = dueInfo(p.due_effective);
|
||||
return (
|
||||
<tr key={p.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/packets/${p.id}`)} onKeyDown={(e) => onKey(e, p.id)}>
|
||||
<td>
|
||||
<b>{jobName(p)}</b>
|
||||
<div className="sub">{p.project.name ?? ""}</div>
|
||||
</td>
|
||||
<td>
|
||||
{p.op ?? ""}
|
||||
<div className="sub">{p.title ?? ""}</div>
|
||||
</td>
|
||||
<td className="small">{p.dept.name}</td>
|
||||
<td>
|
||||
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
|
||||
</td>
|
||||
<td className="mono small">
|
||||
{p.drawing ?? ""} {p.rev ? `R${p.rev}` : ""}
|
||||
</td>
|
||||
<td className="small">
|
||||
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
|
||||
</td>
|
||||
<td className="small mono">{fmtTime(p.updated_at)}</td>
|
||||
<td>
|
||||
{p.open_shorts ? <span className="pill st-review">{p.open_shorts} short</span> : null}
|
||||
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{sorted.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="muted">
|
||||
No packets match.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** /clerical: numbers, attention row, find bar, boards or list, then projects. */
|
||||
export function ClericalHome() {
|
||||
const { me } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
||||
const [view, setView] = useState<BoardView>("board");
|
||||
const [sort, setSort] = useState<SortKey>("updated");
|
||||
const [drafting, setDrafting] = useState(false);
|
||||
const projectId = pick.project?.id;
|
||||
const unitId = pick.unit?.id;
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q]);
|
||||
|
||||
const [load, reload] = useLoad(
|
||||
(s) => packetsApi.list({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
|
||||
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
|
||||
);
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const all = useMemo(() => (load.state === "ok" ? load.data : []), [load]);
|
||||
const b = useMemo(() => clericalBoards(all), [all]);
|
||||
const shown = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
const out: PacketCard[] = [];
|
||||
for (const c of b.columns) {
|
||||
for (const p of c.cards) {
|
||||
if (ids.has(p.id)) continue;
|
||||
ids.add(p.id);
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [b]);
|
||||
const editable = canEditProjects(me?.roles ?? []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="phead">
|
||||
<div>
|
||||
<div className="eyebrow">Projects and job packets</div>
|
||||
<h1>Clerical</h1>
|
||||
</div>
|
||||
{editable ? (
|
||||
<div className="pacts">
|
||||
<button className="btn pri" type="button" onClick={() => setDrafting(true)}>
|
||||
New packet
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: 0, label: "Changes to approve", alert: true },
|
||||
{ n: b.flagged, label: "Flagged", alert: true },
|
||||
{ n: b.warehouse, label: "With warehouse" },
|
||||
{ n: b.shop, label: "In the shop" },
|
||||
{ n: b.toClose, label: "Ready to close", alert: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
{b.flagged || b.toClose ? (
|
||||
<div className="attn" role="status">
|
||||
{b.flagged ? (
|
||||
<div className="callout warn flush">
|
||||
<b>{b.flagged === 1 ? "1 draft is flagged." : `${b.flagged} drafts are flagged.`}</b> Check {b.flagged === 1 ? "it" : "them"} and send {b.flagged === 1 ? "it" : "them"} on.
|
||||
</div>
|
||||
) : null}
|
||||
{b.toClose ? (
|
||||
<div className="callout flush">
|
||||
<b>{b.toClose === 1 ? "1 packet is" : `${b.toClose} packets are`} back from the shop.</b> Close the job in VISUAL, then mark it closed on the packet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} />
|
||||
|
||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||
{load.state === "ok" ? view === "board" ? <Board columns={b.columns} /> : <JobList rows={shown} sort={sort} onSort={setSort} /> : null}
|
||||
|
||||
<ProjectsSection />
|
||||
|
||||
{drafting ? (
|
||||
<NewPacketSheet
|
||||
initial={pick.unit ? pick : { project: pick.project, unit: null }}
|
||||
onCancel={() => setDrafting(false)}
|
||||
onCreated={(p) => {
|
||||
setDrafting(false);
|
||||
navigate(`/packets/${p.id}`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { projectsApi } from "../../api/projects";
|
||||
import type { Project, Unit } from "../../api/types";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { Kpis } from "../../components/Kpis";
|
||||
import { useGuard } from "../../components/useGuard";
|
||||
import { canEditProjects, canRenumber } from "../../lib/access";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { dueInfo, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { ProgressBar, ProjectPill } from "./common";
|
||||
import { AddUnitsSheet, ProjectSheet, RenumberSheet } from "./sheets";
|
||||
|
||||
type UnitShow = "active" | "hold" | "closed" | "all";
|
||||
|
||||
const UNIT_SHOWS: ReadonlyArray<[UnitShow, string]> = [
|
||||
["active", "Active"],
|
||||
["hold", "On hold"],
|
||||
["closed", "Closed"],
|
||||
["all", "All"],
|
||||
];
|
||||
|
||||
function unitsOf(p: Project): Unit[] {
|
||||
return Array.isArray(p.units) ? [...p.units].sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true })) : [];
|
||||
}
|
||||
|
||||
function sumCounts(units: Unit[]) {
|
||||
const c = stageCounts(null);
|
||||
for (const u of units) {
|
||||
const x = stageCounts(u.packets);
|
||||
c.draft += x.draft;
|
||||
c.warehouse += x.warehouse;
|
||||
c.shop += x.shop;
|
||||
c.qc += x.qc;
|
||||
c.complete += x.complete;
|
||||
c.filed += x.filed;
|
||||
c.total += x.total;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function UnitTable({ project, units }: { project: Project; units: Unit[] }) {
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState("");
|
||||
const [show, setShow] = useState<UnitShow>("active");
|
||||
const t = q.trim().toLowerCase();
|
||||
const rows = units.filter(
|
||||
(u) =>
|
||||
(show === "all" || (show === "active" ? u.status !== "closed" : u.status === show)) &&
|
||||
(!t || [u.number, u.lot, u.name ?? "", u.notes ?? ""].join(" ").toLowerCase().includes(t)),
|
||||
);
|
||||
const onKey = (e: KeyboardEvent, id: number) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/clerical/units/${id}`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="unitbar">
|
||||
<label className="f grow">
|
||||
<span className="sr">Find a unit</span>
|
||||
<input className="in" type="search" placeholder="Find a unit: number or description" autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</label>
|
||||
<div className="chips" role="group" aria-label="Show units">
|
||||
{UNIT_SHOWS.map(([k, l]) => (
|
||||
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="small muted">
|
||||
{rows.length} of {units.length} unit{units.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tw">
|
||||
<table className="joblist unitlist">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unit</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Due</th>
|
||||
<th>Packets</th>
|
||||
<th className="wide">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((u) => {
|
||||
const c = stageCounts(u.packets);
|
||||
const own = !!u.due;
|
||||
const di = dueInfo(u.due_effective ?? project.due, u.status === "closed" || project.status === "closed");
|
||||
return (
|
||||
<tr key={u.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/clerical/units/${u.id}`)} onKeyDown={(e) => onKey(e, u.id)}>
|
||||
<td className="mono">
|
||||
<b>{u.number}</b>
|
||||
</td>
|
||||
<td>{u.name || "-"}</td>
|
||||
<td>
|
||||
<ProjectPill status={u.status} />
|
||||
</td>
|
||||
<td className="small">
|
||||
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
|
||||
{own ? null : <span className="muted"> (project)</span>}
|
||||
</td>
|
||||
<td className="small">
|
||||
<b>{c.total}</b>
|
||||
{c.total ? <span className="muted"> · {c.filed} filed</span> : null}
|
||||
</td>
|
||||
<td>
|
||||
<ProgressBar c={c} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="muted">
|
||||
No units match.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** /clerical/projects/:projectId. */
|
||||
export function ProjectPage() {
|
||||
const { projectId } = useParams();
|
||||
const id = Number(projectId);
|
||||
const { me } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const guard = useGuard();
|
||||
const [load, reload] = useLoad((s) => projectsApi.get(id, s), String(id));
|
||||
const [sheet, setSheet] = useState<"edit" | "units" | "renumber" | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const roles = me?.roles ?? [];
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const t = window.setTimeout(() => setToast(null), 2600);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [toast]);
|
||||
|
||||
const back = (
|
||||
<Link className="back" to="/clerical">
|
||||
← All projects
|
||||
</Link>
|
||||
);
|
||||
if (load.state === "loading") return <>{back}<div className="empty slim">Loading</div></>;
|
||||
if (load.state === "error") return <>{back}<div className="callout warn">{load.message}</div></>;
|
||||
|
||||
const p = load.data;
|
||||
const units = unitsOf(p);
|
||||
const c = p.packets ? stageCounts(p.packets) : sumCounts(units);
|
||||
const di = dueInfo(p.due, p.status === "closed");
|
||||
const editable = canEditProjects(roles);
|
||||
const aliases = (p.aliases ?? []).map((a) => (typeof a === "string" ? a : a.alias)).filter((a) => a && a !== p.number);
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<div className="dhead">
|
||||
<div className="grow0">
|
||||
<div className="eyebrow">Project · {PROJECT_STATUS_LABEL[p.status]}</div>
|
||||
<h1>
|
||||
{p.number}
|
||||
{p.name ? ` · ${p.name}` : ""}
|
||||
</h1>
|
||||
<div className="facts">
|
||||
<div>
|
||||
<span>Customer </span>
|
||||
<b>{p.customer || "-"}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Customer PO </span>
|
||||
<b>{p.po || "-"}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Due </span>
|
||||
<b className={di.cls}>{di.txt.replace(/^Due /, "")}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Units </span>
|
||||
<b>{units.length}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Packets </span>
|
||||
<b>{c.total}</b>
|
||||
</div>
|
||||
{aliases.length ? (
|
||||
<div>
|
||||
<span>Old numbers </span>
|
||||
<b>{aliases.join(", ")}</b>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{p.notes ? <div className="small pre">{p.notes}</div> : null}
|
||||
</div>
|
||||
<div className="pacts">
|
||||
<ProjectPill status={p.status} />
|
||||
{editable ? (
|
||||
<button className="btn sm" type="button" onClick={() => setSheet("edit")}>
|
||||
Edit project
|
||||
</button>
|
||||
) : null}
|
||||
{canRenumber(roles) ? (
|
||||
<button className="btn sm" type="button" onClick={() => setSheet("renumber")}>
|
||||
Renumber
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: c.draft, label: "Ready to release" },
|
||||
{ n: c.warehouse, label: "With warehouse" },
|
||||
{ n: c.shop + c.qc, label: "In the shop / Q.C." },
|
||||
{ n: c.complete, label: "Back for data book" },
|
||||
{ n: c.filed, label: "Filed" },
|
||||
]}
|
||||
/>
|
||||
<ProgressBar c={c} />
|
||||
<section className="sec" aria-labelledby="units-h">
|
||||
<div className="sec-h">
|
||||
<h2 id="units-h">Units</h2>
|
||||
<span className="ct">each unit holds the packets for that piece of equipment</span>
|
||||
<span className="grow" />
|
||||
{editable && p.status !== "closed" ? (
|
||||
<button className="btn pri" type="button" onClick={() => setSheet("units")}>
|
||||
Add units
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{p.status === "closed" ? <div className="callout">This project is closed. Reopen it (Edit project) to add units or packets.</div> : null}
|
||||
{units.length ? <UnitTable project={p} units={units} /> : <div className="empty">No units yet.{editable ? " Add one to start adding packets." : ""}</div>}
|
||||
</section>
|
||||
|
||||
{sheet === "edit" ? (
|
||||
<ProjectSheet
|
||||
project={p}
|
||||
onCancel={() => setSheet(null)}
|
||||
onSaved={() => {
|
||||
setSheet(null);
|
||||
setToast("Project saved");
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{sheet === "units" ? (
|
||||
<AddUnitsSheet
|
||||
project={p}
|
||||
lots={units.map((u) => u.lot)}
|
||||
onCancel={() => setSheet(null)}
|
||||
onDone={(added) => {
|
||||
setSheet(null);
|
||||
setToast(added.length === 1 ? `Unit ${added[0]?.number ?? ""} added` : `${added.length} units added`);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{sheet === "renumber" ? (
|
||||
<RenumberSheet
|
||||
kind="project"
|
||||
current={p.number}
|
||||
onCancel={() => setSheet(null)}
|
||||
onSave={async (number, reason) => {
|
||||
const r = await guard.run((g) => projectsApi.renumber(p.id, { number, reason, ...g }), { sub: p.number });
|
||||
if (r === undefined) return;
|
||||
setSheet(null);
|
||||
setToast(`Project renumbered to ${number}`);
|
||||
reload();
|
||||
if (r.id && r.id !== p.id) navigate(`/clerical/projects/${r.id}`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{guard.sheet}
|
||||
{toast ? (
|
||||
<div className="toast" role="status">
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { projectsApi, type ProjectShow } from "../../api/projects";
|
||||
import type { Project } from "../../api/types";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { canEditProjects } from "../../lib/access";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { ProjectCard } from "./common";
|
||||
import { AddUnitsSheet, ProjectSheet } from "./sheets";
|
||||
|
||||
const SHOWS: ReadonlyArray<[ProjectShow, string]> = [
|
||||
["active", "Active"],
|
||||
["open", "Open"],
|
||||
["hold", "On hold"],
|
||||
["closed", "Closed"],
|
||||
["all", "All"],
|
||||
];
|
||||
|
||||
/** Projects on the Clerical home: find box, status chips, cards, New project then Add units. */
|
||||
export function ProjectsSection() {
|
||||
const { me } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [show, setShow] = useState<ProjectShow>("active");
|
||||
const [q, setQ] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [adding, setAdding] = useState<Project | null>(null);
|
||||
const [load, reload] = useLoad((s) => projectsApi.list(query, show, s), `${show}|${query}`);
|
||||
const editable = canEditProjects(me?.roles ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q]);
|
||||
|
||||
useChanged(reload, { delayMs: 500 });
|
||||
|
||||
const list = load.state === "ok" ? load.data : [];
|
||||
return (
|
||||
<section className="sec" aria-labelledby="projects-h">
|
||||
<div className="sec-h">
|
||||
<h2 id="projects-h">Projects</h2>
|
||||
<span className="ct">project, then units, then packets</span>
|
||||
<span className="grow" />
|
||||
<div className="chips" role="group" aria-label="Show projects">
|
||||
{SHOWS.map(([k, l]) => (
|
||||
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{editable ? (
|
||||
<button className="btn pri" type="button" onClick={() => setCreating(true)}>
|
||||
New project
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="unitbar">
|
||||
<label className="f grow">
|
||||
<span className="sr">Find a project</span>
|
||||
<input
|
||||
className="in"
|
||||
type="search"
|
||||
placeholder="Find a project: L670, customer, PO"
|
||||
autoComplete="off"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||
{load.state === "ok" ? (
|
||||
list.length ? (
|
||||
<div className="projgrid">
|
||||
{list.map((p) => (
|
||||
<ProjectCard key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty">{query ? `Nothing matches "${query}".` : "No projects in this view. Set one up, add its units, then add packets into each unit."}</div>
|
||||
)
|
||||
) : null}
|
||||
{creating ? (
|
||||
<ProjectSheet
|
||||
onCancel={() => setCreating(false)}
|
||||
onSaved={(p) => {
|
||||
setCreating(false);
|
||||
setAdding(p);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{adding ? (
|
||||
<AddUnitsSheet
|
||||
project={adding}
|
||||
lots={[]}
|
||||
onCancel={() => setAdding(null)}
|
||||
onDone={() => {
|
||||
const id = adding.id;
|
||||
setAdding(null);
|
||||
navigate(`/clerical/projects/${id}`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { packetsApi } from "../../api/packets";
|
||||
import { projectsApi } from "../../api/projects";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { Board } from "../../components/Board";
|
||||
import { Kpis } from "../../components/Kpis";
|
||||
import { useGuard } from "../../components/useGuard";
|
||||
import { canEditProjects, canRenumber } from "../../lib/access";
|
||||
import { useChanged } from "../../lib/changes";
|
||||
import { countCards, dueInfo, IN_SHOP_OR_QC, PROJECT_STATUS_LABEL } from "../../lib/packets";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { ProgressBar, ProjectPill } from "./common";
|
||||
import { NewPacketSheet, RenumberSheet, UnitSheet } from "./sheets";
|
||||
|
||||
/** /clerical/units/:unitId: the unit's facts and its packets. */
|
||||
export function UnitPage() {
|
||||
const { unitId } = useParams();
|
||||
const id = Number(unitId);
|
||||
const { me } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const guard = useGuard();
|
||||
const [unitLoad, reloadUnit] = useLoad((s) => projectsApi.unit(id, s), String(id));
|
||||
const [packetsLoad, reloadPackets] = useLoad((s) => packetsApi.list({ unit_id: id }, s), String(id));
|
||||
const [sheet, setSheet] = useState<"edit" | "renumber" | "packet" | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const roles = me?.roles ?? [];
|
||||
useChanged(
|
||||
() => {
|
||||
reloadUnit();
|
||||
reloadPackets();
|
||||
},
|
||||
{ delayMs: 500 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const t = window.setTimeout(() => setToast(null), 2600);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [toast]);
|
||||
|
||||
if (unitLoad.state === "loading") return <div className="empty slim">Loading</div>;
|
||||
if (unitLoad.state === "error")
|
||||
return (
|
||||
<>
|
||||
<Link className="back" to="/clerical">
|
||||
← All projects
|
||||
</Link>
|
||||
<div className="callout warn">{unitLoad.message}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const u = unitLoad.data;
|
||||
const t = u.project ?? null;
|
||||
const list = packetsLoad.state === "ok" ? packetsLoad.data : [];
|
||||
const c = countCards(list);
|
||||
const di = dueInfo(u.due_effective, u.status === "closed" || t?.status === "closed");
|
||||
const open = u.status !== "closed" && t?.status !== "closed";
|
||||
const editable = canEditProjects(roles);
|
||||
const grp = (st: string) => list.filter((p) => p.status === st);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="crumbs" aria-label="Where you are">
|
||||
<Link className="back" to="/clerical">
|
||||
All projects
|
||||
</Link>
|
||||
<span>›</span>
|
||||
{t ? (
|
||||
<>
|
||||
<Link className="back" to={`/clerical/projects/${t.id}`}>
|
||||
{t.number} {t.name ?? ""}
|
||||
</Link>
|
||||
<span>›</span>
|
||||
</>
|
||||
) : null}
|
||||
<b>{u.number}</b>
|
||||
</nav>
|
||||
<div className="dhead">
|
||||
<div className="grow0">
|
||||
<div className="eyebrow">
|
||||
Unit{t ? ` of ${t.number}` : ""} · {PROJECT_STATUS_LABEL[u.status]}
|
||||
</div>
|
||||
<h1>
|
||||
{u.number}
|
||||
{u.name ? ` · ${u.name}` : ""}
|
||||
</h1>
|
||||
<div className="facts">
|
||||
<div>
|
||||
<span>Project </span>
|
||||
<b>{t ? `${t.number} ${t.name ?? ""}` : "-"}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Customer </span>
|
||||
<b>{t?.customer || "-"}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Customer PO </span>
|
||||
<b>{t?.po || "-"}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Due </span>
|
||||
<b className={di.cls}>
|
||||
{di.txt.replace(/^Due /, "")}
|
||||
{u.due ? "" : " (project)"}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Packets </span>
|
||||
<b>{c.total}</b>
|
||||
</div>
|
||||
</div>
|
||||
{u.notes ? <div className="small pre">{u.notes}</div> : null}
|
||||
</div>
|
||||
<div className="pacts">
|
||||
<ProjectPill status={u.status} />
|
||||
{editable ? (
|
||||
<button className="btn sm" type="button" onClick={() => setSheet("edit")}>
|
||||
Edit unit
|
||||
</button>
|
||||
) : null}
|
||||
{canRenumber(roles) ? (
|
||||
<button className="btn sm" type="button" onClick={() => setSheet("renumber")}>
|
||||
Renumber
|
||||
</button>
|
||||
) : null}
|
||||
{editable && open ? (
|
||||
<button className="btn pri" type="button" onClick={() => setSheet("packet")}>
|
||||
Add packet
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Kpis
|
||||
items={[
|
||||
{ n: c.draft, label: "Ready to release" },
|
||||
{ n: c.warehouse, label: "With warehouse" },
|
||||
{ n: list.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length, label: "In the shop / Q.C." },
|
||||
{ n: c.complete, label: "Back for data book" },
|
||||
{ n: c.filed, label: "Filed" },
|
||||
]}
|
||||
/>
|
||||
<ProgressBar c={c} />
|
||||
{!open ? <div className="callout">This {u.status === "closed" ? "unit" : "project"} is closed. Reopen it to add packets.</div> : null}
|
||||
{packetsLoad.state === "error" ? <div className="callout warn">{packetsLoad.message}</div> : null}
|
||||
<Board
|
||||
columns={[
|
||||
{ key: "flagged", title: "Flagged", cards: grp("draft").filter((p) => p.flag), empty: "Nothing flagged." },
|
||||
{ key: "draft", title: "Ready to release", cards: grp("draft").filter((p) => !p.flag), empty: "Nothing ready to release in this unit." },
|
||||
{
|
||||
key: "prog",
|
||||
title: "In progress",
|
||||
cards: list.filter((p) => p.status === "warehouse" || IN_SHOP_OR_QC.includes(p.status)),
|
||||
empty: "Nothing in progress.",
|
||||
},
|
||||
{ key: "back", title: "Back for data book", cards: grp("complete"), empty: "Nothing accepted yet." },
|
||||
{ key: "filed", title: "Filed", cards: grp("filed"), empty: "Nothing filed yet." },
|
||||
]}
|
||||
/>
|
||||
|
||||
{sheet === "edit" ? (
|
||||
<UnitSheet
|
||||
unit={u}
|
||||
onCancel={() => setSheet(null)}
|
||||
onSaved={() => {
|
||||
setSheet(null);
|
||||
setToast("Unit saved");
|
||||
reloadUnit();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{sheet === "renumber" ? (
|
||||
<RenumberSheet
|
||||
kind="unit"
|
||||
current={u.lot}
|
||||
prefix={t?.number ?? ""}
|
||||
onCancel={() => setSheet(null)}
|
||||
onSave={async (lot, reason) => {
|
||||
const r = await guard.run((g) => projectsApi.renumberUnit(u.id, { lot, reason, ...g }), { sub: u.number });
|
||||
if (r === undefined) return;
|
||||
setSheet(null);
|
||||
setToast(`Unit renumbered to ${r.number ?? lot}`);
|
||||
reloadUnit();
|
||||
reloadPackets();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{sheet === "packet" ? (
|
||||
<NewPacketSheet
|
||||
initial={{ project: t, unit: u }}
|
||||
onCancel={() => setSheet(null)}
|
||||
onCreated={(p) => {
|
||||
setSheet(null);
|
||||
navigate(`/packets/${p.id}`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{guard.sheet}
|
||||
{toast ? (
|
||||
<div className="toast" role="status">
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// The Clerical home boards, from one list answer.
|
||||
import type { ColumnDef } from "../../components/Board";
|
||||
import type { PacketCard } from "../../api/types";
|
||||
import { IN_SHOP_OR_QC } from "../../lib/packets";
|
||||
|
||||
export const byTime = (a: string | null, b: string | null) => (a ?? "").localeCompare(b ?? "");
|
||||
|
||||
/** The boards of the Clerical home, from one list answer. */
|
||||
export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagged: number; warehouse: number; shop: number; toClose: number } {
|
||||
const drafts = all.filter((p) => p.status === "draft").sort((a, b) => byTime(a.updated_at, b.updated_at));
|
||||
const back = all.filter((p) => p.status === "complete" && p.routed_to === "databook");
|
||||
const flagged = drafts.filter((p) => p.flag);
|
||||
const going = drafts.filter((p) => !p.flag);
|
||||
const toClose = back.filter((p) => !p.vclosed).sort((a, b) => byTime(a.status_at, b.status_at));
|
||||
const toBook = back.filter((p) => p.vclosed).sort((a, b) => byTime(a.vclosed?.at ?? null, b.vclosed?.at ?? null));
|
||||
const filed = all
|
||||
.filter((p) => p.status === "filed")
|
||||
.sort((a, b) => byTime(b.status_at, a.status_at))
|
||||
.slice(0, 25);
|
||||
const columns: ColumnDef[] = [
|
||||
{ key: "cos", title: "Changes to approve", cards: [], empty: "Arrives in phase 3 with the VISUAL feed." },
|
||||
{ key: "flagged", title: "Flagged", cards: flagged, empty: "Nothing flagged." },
|
||||
{ key: "going", title: "Going to the warehouse", cards: going, empty: "Drafts with nothing flagged wait here." },
|
||||
{
|
||||
key: "close",
|
||||
title: "Ready to close",
|
||||
cards: toClose,
|
||||
empty: "Finished packets come back here. Close the job in VISUAL, then mark it closed on the packet.",
|
||||
},
|
||||
{ key: "book", title: "Ready for data book", cards: toBook, empty: "Jobs closed in VISUAL wait here for the data book." },
|
||||
{ key: "filed", title: "Filed", cards: filed, empty: "Nothing filed yet." },
|
||||
];
|
||||
return {
|
||||
columns,
|
||||
flagged: flagged.length,
|
||||
warehouse: all.filter((p) => p.status === "warehouse").length,
|
||||
shop: all.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length,
|
||||
toClose: toClose.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Link } from "react-router";
|
||||
import type { Project, StageCounts } from "../../api/types";
|
||||
import { dueInfo, projectPillClass, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
|
||||
|
||||
/** The old stageChips(): how many packets sit at each stage. */
|
||||
export function StageChips({ c }: { c: StageCounts }) {
|
||||
const items: Array<[string, number]> = [
|
||||
["Ready to release", c.draft],
|
||||
["Warehouse", c.warehouse],
|
||||
["Shop", c.shop],
|
||||
["Q.C.", c.qc],
|
||||
["Accepted", c.complete],
|
||||
["Filed", c.filed],
|
||||
];
|
||||
return (
|
||||
<div className="stg">
|
||||
{items.map(([l, n]) => (
|
||||
<span key={l} className={n ? undefined : "z"}>
|
||||
<b>{n}</b> {l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The old progressBar(): filed in cyan, accepted in green. */
|
||||
export function ProgressBar({ c }: { c: StageCounts }) {
|
||||
const n = c.total || 0;
|
||||
const f = n ? (c.filed / n) * 100 : 0;
|
||||
const a = n ? (c.complete / n) * 100 : 0;
|
||||
return (
|
||||
<div className="pbar" title={`${c.filed} filed, ${c.complete} accepted, ${Math.max(0, n - c.filed - c.complete)} in progress of ${n}`}>
|
||||
<i className="f" style={{ width: `${f}%` }} />
|
||||
<i className="c" style={{ width: `${a}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectPill({ status }: { status: Project["status"] }) {
|
||||
return <span className={projectPillClass(status)}>{PROJECT_STATUS_LABEL[status]}</span>;
|
||||
}
|
||||
|
||||
/** The old projCard(). */
|
||||
export function ProjectCard({ p }: { p: Project }) {
|
||||
const c = stageCounts(p.packets);
|
||||
const di = dueInfo(p.due, p.status === "closed");
|
||||
const units = typeof p.units === "number" ? p.units : p.units.length;
|
||||
return (
|
||||
<Link className="card pcard" to={`/clerical/projects/${p.id}`}>
|
||||
<div className="r1">
|
||||
<span className="job">{p.number}</span>
|
||||
<ProjectPill status={p.status} />
|
||||
</div>
|
||||
<div className="tt">{p.name || "-"}</div>
|
||||
<div className="small muted">
|
||||
{p.customer ?? ""}
|
||||
{p.po ? (
|
||||
<>
|
||||
{" "}
|
||||
· PO <span className="mono">{p.po}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={`small ${di.cls}`}>{di.txt}</div>
|
||||
<StageChips c={c} />
|
||||
<ProgressBar c={c} />
|
||||
<div className="small muted">
|
||||
{units} unit{units === 1 ? "" : "s"} · {c.total} packet{c.total === 1 ? "" : "s"} · {c.filed} filed
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { packetsApi } from "../../api/packets";
|
||||
import { projectsApi, type ProjectFields, type UnitFields } from "../../api/projects";
|
||||
import type { PacketCreate, PacketRead, Project, ProjectRef, ProjectStatus, Unit } from "../../api/types";
|
||||
import { PickUnit, type UnitPick } from "../../components/PickUnit";
|
||||
import { Sheet } from "../../components/Sheet";
|
||||
import { useGuard } from "../../components/useGuard";
|
||||
import { errorText } from "../../lib/format";
|
||||
import { useShopDepartments } from "../../lib/useDepartments";
|
||||
import { lotSequence, nextLot, normLot, PROJECT_STATUS_LABEL, RX_LOT, RX_PROJECT } from "../../lib/packets";
|
||||
|
||||
const STATUSES: ProjectStatus[] = ["open", "hold", "closed"];
|
||||
|
||||
function Problem({ text }: { text: string | null }) {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The keys whose value changed, with the old values as the base. */
|
||||
function diff<T extends object>(before: T, after: T): { set: Partial<T>; base: Partial<T> } {
|
||||
const set: Partial<T> = {};
|
||||
const base: Partial<T> = {};
|
||||
for (const k of Object.keys(after) as Array<keyof T>) {
|
||||
if ((before[k] ?? null) !== (after[k] ?? null)) {
|
||||
set[k] = after[k];
|
||||
base[k] = before[k];
|
||||
}
|
||||
}
|
||||
return { set, base };
|
||||
}
|
||||
|
||||
const orNull = (s: string) => (s.trim() === "" ? null : s.trim());
|
||||
|
||||
/* ---------- Project: new or edit ---------- */
|
||||
|
||||
export function ProjectSheet({ project, onSaved, onCancel }: { project?: Project; onSaved: (p: Project) => void; onCancel: () => void }) {
|
||||
const guard = useGuard();
|
||||
const editing = !!project;
|
||||
const [number, setNumber] = useState(project?.number ?? "");
|
||||
const [name, setName] = useState(project?.name ?? "");
|
||||
const [customer, setCustomer] = useState(project?.customer ?? "");
|
||||
const [po, setPo] = useState(project?.po ?? "");
|
||||
const [due, setDue] = useState(project?.due ?? "");
|
||||
const [status, setStatus] = useState<ProjectStatus>(project?.status ?? "open");
|
||||
const [notes, setNotes] = useState(project?.notes ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const num = number.trim().toUpperCase();
|
||||
if (!editing && !RX_PROJECT.test(num)) {
|
||||
setError("Project number: 1 to 20 letters, numbers or dashes, for example L670.");
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setError("Give the project a name.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
let saved: Project | undefined;
|
||||
if (project) {
|
||||
const before: ProjectFields = { name: project.name, customer: project.customer, po: project.po, due: project.due, status: project.status, notes: project.notes };
|
||||
const after: ProjectFields = { name: orNull(name), customer: orNull(customer), po: orNull(po), due: orNull(due), status, notes: orNull(notes) };
|
||||
const cas = diff(before, after);
|
||||
if (Object.keys(cas.set).length === 0) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
saved = await guard.run((g) => projectsApi.patch(project.id, { ...cas, ...g }), { sub: project.number });
|
||||
} else {
|
||||
saved = await guard.run((g) =>
|
||||
projectsApi.create({ number: num, name: name.trim(), customer: customer.trim(), po: po.trim(), due: orNull(due), notes: notes.trim(), ...g }),
|
||||
);
|
||||
}
|
||||
if (saved) onSaved(saved);
|
||||
else setBusy(false);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet title={editing ? "Edit project" : "New project"} sub={project?.number} onClose={onCancel} wide>
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<div className="frow">
|
||||
{editing ? null : (
|
||||
<label className="f">
|
||||
Project number
|
||||
<input className="in" value={number} maxLength={20} placeholder="e.g. L670" autoCapitalize="characters" onChange={(e) => setNumber(e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="f">
|
||||
Project name
|
||||
<input className="in" value={name} maxLength={120} placeholder="e.g. BPX separator package" onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Customer
|
||||
<input className="in" value={customer} maxLength={120} onChange={(e) => setCustomer(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Customer PO
|
||||
<input className="in" value={po} maxLength={60} onChange={(e) => setPo(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Due / ship date
|
||||
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||
</label>
|
||||
{editing ? (
|
||||
<label className="f">
|
||||
Status
|
||||
<select className="in" value={status} onChange={(e) => setStatus(e.target.value as ProjectStatus)}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{PROJECT_STATUS_LABEL[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="f">
|
||||
Notes
|
||||
<textarea className="in" rows={3} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</label>
|
||||
{editing ? null : <div className="small muted">After creating the project, add its units. Packets go into a unit.</div>}
|
||||
<Problem text={error} />
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
{editing ? "Save changes" : "Create project"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
{guard.sheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Add units: one, or several counting up ---------- */
|
||||
|
||||
export function AddUnitsSheet({
|
||||
project,
|
||||
lots,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
project: ProjectRef;
|
||||
/** Lots already in use, for the next free number. */
|
||||
lots: string[];
|
||||
onDone: (units: Unit[]) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const guard = useGuard();
|
||||
const first = nextLot(lots);
|
||||
const [many, setMany] = useState(false);
|
||||
const [lot, setLot] = useState(first);
|
||||
const [count, setCount] = useState("");
|
||||
const [start, setStart] = useState(first);
|
||||
const [name, setName] = useState("");
|
||||
const [due, setDue] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const seq = many ? lotSequence(start, Number(count)) : null;
|
||||
const preview = seq?.map((l) => `${project.number}${l}`) ?? [];
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
let body: { lot?: string; lots?: string[] };
|
||||
if (many) {
|
||||
const n = Number(count);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 200) {
|
||||
setError("How many: 1 to 200.");
|
||||
return;
|
||||
}
|
||||
if (!seq) {
|
||||
setError("The starting lot has to be digits, for example 001, and the last one 999 or less.");
|
||||
return;
|
||||
}
|
||||
body = { lots: seq };
|
||||
} else {
|
||||
const l = normLot(lot);
|
||||
if (!RX_LOT.test(l)) {
|
||||
setError("Lot: 1 to 3 letters or digits, for example 001 or 7A.");
|
||||
return;
|
||||
}
|
||||
body = { lot: l };
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const units = await guard.run((g) => projectsApi.addUnits(project.id, { ...body, name: name.trim(), due: orNull(due), notes: notes.trim(), ...g }), { sub: project.number });
|
||||
if (units) onDone(units);
|
||||
else setBusy(false);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet title="Add units" sub={`Project ${project.number}${project.name ? ` · ${project.name}` : ""}`} onClose={onCancel} wide>
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<div className="seg" role="group" aria-label="One or several">
|
||||
<button type="button" aria-pressed={!many} onClick={() => setMany(false)}>
|
||||
One unit
|
||||
</button>
|
||||
<button type="button" aria-pressed={many} onClick={() => setMany(true)}>
|
||||
Several units
|
||||
</button>
|
||||
</div>
|
||||
{many ? (
|
||||
<>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
How many
|
||||
<input className="in" type="number" min={1} max={200} inputMode="numeric" placeholder="e.g. 12" value={count} onChange={(e) => setCount(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Starting lot
|
||||
<input className="in mono" value={start} maxLength={3} onChange={(e) => setStart(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="small muted" aria-live="polite">
|
||||
{preview.length
|
||||
? preview.length <= 12
|
||||
? `Numbers: ${preview.join(", ")}`
|
||||
: `Numbers: ${preview.slice(0, 6).join(", ")} and ${preview.length - 6} more, up to ${preview[preview.length - 1] ?? ""}`
|
||||
: "Numbers count up from the starting lot."}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Lot
|
||||
<input className="in mono" value={lot} maxLength={3} autoCapitalize="characters" onChange={(e) => setLot(e.target.value)} />
|
||||
</label>
|
||||
<div className="end small muted">
|
||||
Unit number <b className="mono">{`${project.number}${normLot(lot)}`}</b>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
{many ? "Description (all of them)" : "Description"}
|
||||
<input className="in" value={name} maxLength={120} placeholder='e.g. 48" horizontal separator' onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Due / ship date
|
||||
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="small muted">Leave the date blank to use the project's date.</div>
|
||||
<label className="f">
|
||||
Notes
|
||||
<textarea className="in" rows={2} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</label>
|
||||
<Problem text={error} />
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
{guard.sheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Edit a unit ---------- */
|
||||
|
||||
export function UnitSheet({ unit, onSaved, onCancel }: { unit: Unit; onSaved: (u: Unit) => void; onCancel: () => void }) {
|
||||
const guard = useGuard();
|
||||
const [name, setName] = useState(unit.name ?? "");
|
||||
const [due, setDue] = useState(unit.due ?? "");
|
||||
const [status, setStatus] = useState<ProjectStatus>(unit.status);
|
||||
const [notes, setNotes] = useState(unit.notes ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const before: UnitFields = { name: unit.name, due: unit.due, status: unit.status, notes: unit.notes ?? null };
|
||||
const cas = diff(before, { name: orNull(name), due: orNull(due), status, notes: orNull(notes) });
|
||||
if (Object.keys(cas.set).length === 0) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await guard.run((g) => projectsApi.patchUnit(unit.id, { ...cas, ...g }), { sub: unit.number });
|
||||
if (saved) onSaved(saved);
|
||||
else setBusy(false);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet title="Edit unit" sub={unit.number} onClose={onCancel} wide>
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Description
|
||||
<input className="in" value={name} maxLength={120} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Due / ship date
|
||||
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Status
|
||||
<select className="in" value={status} onChange={(e) => setStatus(e.target.value as ProjectStatus)}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{PROJECT_STATUS_LABEL[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="f">
|
||||
Notes
|
||||
<textarea className="in" rows={2} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</label>
|
||||
<Problem text={error} />
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
{guard.sheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Renumber a project or a unit ---------- */
|
||||
|
||||
export function RenumberSheet({
|
||||
kind,
|
||||
current,
|
||||
prefix,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
kind: "project" | "unit";
|
||||
/** The current project number, or the unit's lot. */
|
||||
current: string;
|
||||
/** For a unit: the project number, to show the new unit number. */
|
||||
prefix?: string;
|
||||
onSave: (value: string, reason: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const v = kind === "project" ? value.trim().toUpperCase() : normLot(value);
|
||||
if (kind === "project" ? !RX_PROJECT.test(v) : !RX_LOT.test(v)) {
|
||||
setError(kind === "project" ? "Project number: 1 to 20 letters, numbers or dashes." : "Lot: 1 to 3 letters or digits.");
|
||||
return;
|
||||
}
|
||||
if (v === current) {
|
||||
setError("That is the current number.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError("Give a reason.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSave(v, reason.trim());
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = kind === "project" ? "Renumber project" : "Renumber unit";
|
||||
return (
|
||||
<Sheet title={title} sub={kind === "project" ? current : `${prefix ?? ""}${current}`} onClose={onCancel}>
|
||||
<div className="small">
|
||||
{kind === "project"
|
||||
? "The old number stays as an alias, so prints with it still find this project. Its units keep their lots and move with it."
|
||||
: "The old number stays as an alias. The unit's packets move with it."}
|
||||
</div>
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<label className="f">
|
||||
{kind === "project" ? "New project number" : "New lot"}
|
||||
<input className="in mono" value={value} maxLength={kind === "project" ? 20 : 3} autoCapitalize="characters" onChange={(e) => setValue(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Reason
|
||||
<input className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<Problem text={error} />
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Change number
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- New packet: a hand draft ---------- */
|
||||
|
||||
interface WoRow {
|
||||
wo_base: string;
|
||||
lot: string;
|
||||
split: string;
|
||||
sub: string;
|
||||
qty: string;
|
||||
target_hrs: string;
|
||||
}
|
||||
|
||||
interface BomRow {
|
||||
qty: string;
|
||||
part_id: string;
|
||||
description: string;
|
||||
material: string;
|
||||
length: string;
|
||||
dwg_ref: string;
|
||||
}
|
||||
|
||||
const EMPTY_WO: WoRow = { wo_base: "", lot: "", split: "", sub: "", qty: "", target_hrs: "" };
|
||||
const EMPTY_BOM: BomRow = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
|
||||
|
||||
const num = (s: string): number | null => {
|
||||
const t = s.trim();
|
||||
if (t === "") return null;
|
||||
const n = Number(t);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
export function NewPacketSheet({
|
||||
initial,
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
initial: UnitPick;
|
||||
onCreated: (p: PacketRead) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const guard = useGuard();
|
||||
const depts = useShopDepartments();
|
||||
const [pick, setPick] = useState<UnitPick>(initial);
|
||||
const [dept, setDept] = useState("");
|
||||
const [op, setOp] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [customer, setCustomer] = useState(initial.project?.customer ?? "");
|
||||
const [drawing, setDrawing] = useState("");
|
||||
const [rev, setRev] = useState("");
|
||||
const [wps, setWps] = useState("");
|
||||
const [wos, setWos] = useState<WoRow[]>([{ ...EMPTY_WO }]);
|
||||
const [bom, setBom] = useState<BomRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deptValue = dept || depts[0]?.code || "";
|
||||
|
||||
const onPick = (v: UnitPick) => {
|
||||
setPick(v);
|
||||
if (v.project?.customer && !customer) setCustomer(v.project.customer);
|
||||
};
|
||||
|
||||
const setWo = (i: number, k: keyof WoRow, v: string) => setWos((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
||||
const setBl = (i: number, k: keyof BomRow, v: string) => setBom((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!pick.unit) {
|
||||
setError("Pick the project and the unit first.");
|
||||
return;
|
||||
}
|
||||
if (!deptValue) {
|
||||
setError("Pick the first department.");
|
||||
return;
|
||||
}
|
||||
if (!op.trim()) {
|
||||
setError("Give the operation, for example WELD-PART.");
|
||||
return;
|
||||
}
|
||||
const work_orders = wos
|
||||
.filter((w) => w.wo_base.trim())
|
||||
.map((w) => ({
|
||||
wo_base: w.wo_base.trim().toUpperCase(),
|
||||
lot: w.lot.trim().toUpperCase(),
|
||||
split: w.split.trim().toUpperCase(),
|
||||
sub: w.sub.trim().toUpperCase(),
|
||||
qty: num(w.qty),
|
||||
target_hrs: num(w.target_hrs),
|
||||
}));
|
||||
const lines = bom
|
||||
.filter((b) => b.part_id.trim() || b.description.trim())
|
||||
.map((b, i) => ({
|
||||
item: i + 1,
|
||||
qty: num(b.qty),
|
||||
part_id: b.part_id.trim() || null,
|
||||
description: b.description.trim() || null,
|
||||
material: b.material.trim() || null,
|
||||
length: b.length.trim() || null,
|
||||
dwg_ref: b.dwg_ref.trim() || null,
|
||||
}));
|
||||
const body: PacketCreate = {
|
||||
unit_id: pick.unit.id,
|
||||
dept_code: deptValue,
|
||||
op: op.trim().toUpperCase(),
|
||||
title: title.trim(),
|
||||
customer: customer.trim(),
|
||||
drawing: drawing.trim(),
|
||||
rev: rev.trim(),
|
||||
wps: wps.trim(),
|
||||
work_orders,
|
||||
bom: lines,
|
||||
};
|
||||
setBusy(true);
|
||||
try {
|
||||
const created = await guard.run((g) => packetsApi.create({ ...body, ...g } as PacketCreate));
|
||||
if (created) onCreated(created);
|
||||
else setBusy(false);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet title="New packet" sub="Made by hand" onClose={onCancel} wide>
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<PickUnit value={pick} onChange={onPick} requireUnit />
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
First department
|
||||
<select className="in" value={deptValue} onChange={(e) => setDept(e.target.value)}>
|
||||
{depts.map((d) => (
|
||||
<option key={d.code} value={d.code}>
|
||||
{d.code} {d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="f">
|
||||
Operation
|
||||
<input className="in" value={op} maxLength={40} placeholder="e.g. WELD-PART" autoCapitalize="characters" onChange={(e) => setOp(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Title
|
||||
<input className="in" value={title} maxLength={200} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Customer
|
||||
<input className="in" value={customer} maxLength={120} onChange={(e) => setCustomer(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Drawing no.
|
||||
<input className="in" value={drawing} maxLength={60} onChange={(e) => setDrawing(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Rev
|
||||
<input className="in" value={rev} maxLength={10} onChange={(e) => setRev(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
WPS
|
||||
<input className="in" value={wps} maxLength={40} onChange={(e) => setWps(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
Work orders <span className="muted mono small">({wos.filter((w) => w.wo_base.trim()).length})</span>
|
||||
</h3>
|
||||
<div className="tw">
|
||||
<table className="edit">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>WO</th>
|
||||
<th>Lot</th>
|
||||
<th>Split</th>
|
||||
<th>Sub</th>
|
||||
<th className="num">Qty</th>
|
||||
<th className="num">Target hrs</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{wos.map((w, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input className="in sm mono" aria-label={`Work order ${i + 1}`} value={w.wo_base} maxLength={30} onChange={(e) => setWo(i, "wo_base", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm mono" aria-label={`Work order ${i + 1} lot`} value={w.lot} maxLength={10} onChange={(e) => setWo(i, "lot", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm mono" aria-label={`Work order ${i + 1} split`} value={w.split} maxLength={10} onChange={(e) => setWo(i, "split", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm mono" aria-label={`Work order ${i + 1} sub`} value={w.sub} maxLength={10} onChange={(e) => setWo(i, "sub", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm num" aria-label={`Work order ${i + 1} quantity`} inputMode="decimal" value={w.qty} onChange={(e) => setWo(i, "qty", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm num" aria-label={`Work order ${i + 1} target hours`} inputMode="decimal" value={w.target_hrs} onChange={(e) => setWo(i, "target_hrs", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn sm ghost" type="button" aria-label={`Remove work order ${i + 1}`} onClick={() => setWos((rows) => rows.filter((_, j) => j !== i))}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn sm" type="button" onClick={() => setWos((rows) => [...rows, { ...EMPTY_WO }])}>
|
||||
Add work order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
Bill of materials <span className="muted mono small">(optional, {bom.length} lines)</span>
|
||||
</h3>
|
||||
{bom.length ? (
|
||||
<div className="tw">
|
||||
<table className="edit">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th className="num">Qty</th>
|
||||
<th>Part ID</th>
|
||||
<th>Description</th>
|
||||
<th>Material</th>
|
||||
<th>Length</th>
|
||||
<th>Dwg ref</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bom.map((b, i) => (
|
||||
<tr key={i}>
|
||||
<td className="mono">{i + 1}</td>
|
||||
<td>
|
||||
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={b.qty} onChange={(e) => setBl(i, "qty", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} value={b.part_id} maxLength={60} onChange={(e) => setBl(i, "part_id", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm" aria-label={`Line ${i + 1} description`} value={b.description} maxLength={240} onChange={(e) => setBl(i, "description", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm" aria-label={`Line ${i + 1} material`} value={b.material} maxLength={120} onChange={(e) => setBl(i, "material", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm" aria-label={`Line ${i + 1} length`} value={b.length} maxLength={40} onChange={(e) => setBl(i, "length", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} value={b.dwg_ref} maxLength={60} onChange={(e) => setBl(i, "dwg_ref", e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setBom((rows) => rows.filter((_, j) => j !== i))}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<button className="btn sm" type="button" onClick={() => setBom((rows) => [...rows, { ...EMPTY_BOM }])}>
|
||||
Add BOM line
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="small muted">The packet is saved as a draft. Attach the drawing and say whether it needs a heat map before you send it on.</div>
|
||||
<Problem text={error} />
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Discard
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Save packet as draft
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
{guard.sheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Synthetic packet answers for the screen tests. No real job data.
|
||||
import type { PacketCard, PacketRead, Project, Unit } from "../api/types";
|
||||
|
||||
export function makeCard(over: Partial<PacketCard> = {}): PacketCard {
|
||||
return {
|
||||
id: 1,
|
||||
version: 1,
|
||||
unit: { id: 11, number: "L999001" },
|
||||
project: { id: 7, number: "L999", name: "CI project" },
|
||||
dept: { code: "03", name: "Weld Parts" },
|
||||
op: "WELD-PART",
|
||||
title: "CI spool",
|
||||
customer: "Test Co",
|
||||
drawing: "CI-DWG-1",
|
||||
rev: "0",
|
||||
status: "draft",
|
||||
status_at: "2026-10-16T08:00:00Z",
|
||||
station: null,
|
||||
welder: null,
|
||||
routed_to: null,
|
||||
vclosed: null,
|
||||
source_kind: "hand",
|
||||
flag: null,
|
||||
open_cos: [],
|
||||
open_shorts: 0,
|
||||
wh_stage: null,
|
||||
due_effective: null,
|
||||
updated_at: "2026-10-16T08:00:00Z",
|
||||
holder: "Clerical",
|
||||
can: [],
|
||||
blocked: {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function makePacket(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return {
|
||||
id: 1,
|
||||
version: 3,
|
||||
unit: {
|
||||
id: 11,
|
||||
number: "L999001",
|
||||
lot: "001",
|
||||
name: null,
|
||||
project: { id: 7, number: "L999", name: "CI project", customer: "Test Co", po: null, status: "open" },
|
||||
due_effective: null,
|
||||
},
|
||||
dept: { code: "03", name: "Weld Parts", weld: true },
|
||||
op: "WELD-PART",
|
||||
title: "CI spool",
|
||||
customer: "Test Co",
|
||||
drawing: "CI-DWG-1",
|
||||
rev: "0",
|
||||
wps: null,
|
||||
status: "draft",
|
||||
status_at: "2026-10-16T08:00:00Z",
|
||||
station: null,
|
||||
welder: null,
|
||||
for_sup: null,
|
||||
heat_map: null,
|
||||
wh_skipped: false,
|
||||
wh_sent_at: null,
|
||||
routed_to: null,
|
||||
return_note: null,
|
||||
vclosed: null,
|
||||
databook_at: null,
|
||||
source_kind: "hand",
|
||||
source: { kind: "hand" },
|
||||
drawing_state: "none",
|
||||
flags: ["hand"],
|
||||
visual: {},
|
||||
removed: null,
|
||||
work_orders: [{ id: 1, wo_base: "L999001", lot: "001", split: "0", sub: "0", qty: 1, target_hrs: 2, row_version: 1 }],
|
||||
pages: [],
|
||||
bom: [],
|
||||
welds: [],
|
||||
heat_tags: [],
|
||||
heat_table: [],
|
||||
stage: null,
|
||||
stages: [],
|
||||
wh: null,
|
||||
qc: null,
|
||||
qc_history: [],
|
||||
change_orders: [],
|
||||
open_cos: [],
|
||||
open_shorts: 0,
|
||||
docs: [],
|
||||
seal: null,
|
||||
can: ["edit_header", "edit_drawing_ref", "edit_document_set", "move_unit", "remove"],
|
||||
blocked: { send_to_warehouse: ["Say whether this packet needs a heat map."] },
|
||||
holder: "Clerical",
|
||||
checklist_items: [],
|
||||
qc_items: [],
|
||||
flag: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeProject(over: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: 7,
|
||||
number: "L999",
|
||||
name: "CI project",
|
||||
customer: "Test Co",
|
||||
po: null,
|
||||
due: null,
|
||||
status: "open",
|
||||
notes: null,
|
||||
units: 1,
|
||||
packets: { draft: 1, warehouse: 0, shop: 0, qc: 0, complete: 0, filed: 0, total: 1 },
|
||||
version: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeUnit(over: Partial<Unit> = {}): Unit {
|
||||
return {
|
||||
id: 11,
|
||||
project_id: 7,
|
||||
number: "L999001",
|
||||
lot: "001",
|
||||
name: null,
|
||||
due: null,
|
||||
due_effective: null,
|
||||
status: "open",
|
||||
notes: null,
|
||||
version: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -263,3 +263,90 @@ tr.rowlink.on td { background: var(--sel); }
|
||||
tr.rowlink:focus-visible { outline: 2px solid var(--cyan); outline-offset: -2px; }
|
||||
td .pill + .pill { margin-left: 4px; }
|
||||
.lock .auth-card { max-height: calc(100dvh - 32px); overflow: auto; }
|
||||
|
||||
/* ===== New in the rebuild (phase 2) ===== */
|
||||
/* Clerical home, projects and units, the packet page. Ported from the old .dhead, .facts, .projgrid, .pcard,
|
||||
.stg, .pbar, .unitbar, .projhits, .crumbs, .due, .fltbar, .qrs, .balloon blocks, plus a few new helpers. */
|
||||
.sheet.narrow { width: min(560px, 100%); }
|
||||
.sec-h.flush { margin: 0; }
|
||||
.chip.sm { min-height: 34px; padding: 4px 12px; font-size: 13.5px; }
|
||||
.grow0 { min-width: 0; flex: 1 1 420px; }
|
||||
.pre { white-space: pre-wrap; margin-top: 6px; }
|
||||
.dhead { display: flex; gap: 18px; align-items: flex-end; justify-content: space-between; flex-wrap: wrap; padding-block: 6px 14px; border-bottom: 1px solid var(--line); margin-bottom: 12px; }
|
||||
.dhead .facts { display: flex; gap: 6px 22px; flex-wrap: wrap; margin-top: 8px; font-size: 13.5px; }
|
||||
.dhead .facts span { color: var(--muted); }
|
||||
.dhead .facts b { color: var(--near); font-weight: 600; font-family: var(--f-mono); font-size: 13px; }
|
||||
.dhead .pacts { align-items: center; }
|
||||
.dtitle { color: var(--near); font-weight: 500; font-size: 17px; margin-top: 2px; }
|
||||
.crumbs2 { margin: 4px 0 6px; }
|
||||
.crumbs2 b { color: var(--near); }
|
||||
.crumbs { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; font-size: 14px; }
|
||||
.crumbs span { color: var(--muted); }
|
||||
.crumbs b { color: var(--near); }
|
||||
.due { font-weight: 600; }
|
||||
.due.over { color: var(--pink); font-weight: 700; }
|
||||
.due.soon { color: var(--amber); font-weight: 700; }
|
||||
.due.muted { opacity: .7; }
|
||||
.flagline { margin: 4px 0 8px; }
|
||||
.card .dept { color: var(--cyan); font-weight: 600; }
|
||||
.attn { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||
.fltbar { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; margin: 8px 0 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.fltrow { display: flex; gap: 8px 12px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.fltrow .f { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.fltrow .f > span { font-size: 11.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
|
||||
.fltrow .fltq { flex: 3 1 320px; }
|
||||
.fltrow .fltp { flex: 1.4 1 260px; }
|
||||
.fltbtns { display: flex; gap: 8px; align-items: flex-end; flex: none; }
|
||||
.fltrow2 { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.pick { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pickp { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; color: var(--near); }
|
||||
.pickp .small { font-weight: 700; letter-spacing: .06em; text-transform: uppercase; font-size: 11.5px; }
|
||||
.projhits { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow: auto; }
|
||||
.projhit { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; color: var(--near); cursor: pointer; font: inherit; }
|
||||
.projhit:hover, .projhit:focus-visible { border-color: var(--cyan); }
|
||||
.projhit .pill { margin-left: auto; }
|
||||
.projgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.pcard .job { font-size: 20px; }
|
||||
.stg { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; margin-top: 6px; }
|
||||
.stg span { color: var(--near); }
|
||||
.stg span.z { color: var(--muted); opacity: .6; }
|
||||
.pbar { height: 8px; border-radius: 5px; background: var(--line2); overflow: hidden; display: flex; margin: 6px 0 2px; }
|
||||
.pbar i { display: block; height: 100%; }
|
||||
.pbar i.f { background: var(--cyan); }
|
||||
.pbar i.c { background: var(--green); }
|
||||
.unitbar { display: flex; gap: 10px 14px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.unitlist .pbar { margin: 0; }
|
||||
th.wide { min-width: 160px; }
|
||||
table.edit td { padding: 6px; }
|
||||
input.in.sm { min-height: 36px; padding: 6px 8px; font-size: 14px; }
|
||||
input.in.num { text-align: right; font-family: var(--f-mono); }
|
||||
.sheet-acts.start { justify-content: flex-start; align-items: center; }
|
||||
.sigmean { border-left: 3px solid var(--amber); background: var(--panel); border-radius: 0 10px 10px 0; padding: 8px 12px; }
|
||||
.sigmean .tt { color: var(--near); font-weight: 600; font-size: 16px; margin-top: 2px; }
|
||||
.actbox { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.hmq { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.hmq b { color: var(--near); }
|
||||
.acts { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-start; }
|
||||
.act { display: flex; flex-direction: column; gap: 4px; max-width: 280px; }
|
||||
.act .why { color: var(--amber); }
|
||||
label.btn input.sr { pointer-events: none; }
|
||||
.balloon { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid var(--cyan); color: var(--cyan); font-family: var(--f-mono); font-size: 12.5px; font-weight: 600; }
|
||||
.qrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
||||
.qr { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; display: flex; gap: 16px; align-items: center; }
|
||||
.qr .code { background: var(--white); padding: 10px; border-radius: 8px; flex: none; width: 148px; height: 148px; display: grid; place-items: center; color: var(--bg); }
|
||||
.qr .code canvas { width: 128px !important; height: 128px !important; }
|
||||
.qr .wo { font-family: var(--f-disp); font-weight: 700; font-size: 26px; color: var(--white); letter-spacing: .03em; }
|
||||
.pdfv { display: flex; flex-direction: column; gap: 10px; }
|
||||
.pdfv-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.pdfv-bar .seg button { min-width: 44px; font-weight: 700; }
|
||||
.pdfv-body { display: grid; grid-template-columns: 150px 1fr; gap: 12px; align-items: start; }
|
||||
.pdfv-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.pdfv-pages button { width: 100%; text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; color: var(--near); cursor: pointer; font: inherit; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
.pdfv-pages button[aria-current="page"] { border-color: var(--cyan); background: var(--sel); }
|
||||
.pdfv-frame { overflow: auto; background: var(--col); border: 1px solid var(--line); border-radius: 10px; padding: 1px; min-height: 240px; max-height: calc(100dvh - 160px); }
|
||||
.pdfv-canvas { display: block; background: var(--white); }
|
||||
@media (max-width: 700px) {
|
||||
.pdfv-body { grid-template-columns: 1fr; }
|
||||
.pdfv-pages { flex-direction: row; flex-wrap: wrap; }
|
||||
.pdfv-pages button { width: auto; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user