Compare commits
19 Commits
fc61b4cdb6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d9b80971c | |||
| a76f92dbdd | |||
| c7c3d46c62 | |||
| ee25bb6065 | |||
| c872566e83 | |||
| c911311596 | |||
| 08307119da | |||
| 8c6c26bc8e | |||
| 3cb086d67b | |||
| c7cea78f74 | |||
| 4ed551e253 | |||
| 98eb91af71 | |||
| e5c2b047d7 | |||
| b667fe1082 | |||
| 3cf9e2997c | |||
| c1e3212e07 | |||
| 89daec453b | |||
| 76888915e7 | |||
| 9d2acd126c |
3
.env
3
.env
@@ -1,2 +1 @@
|
||||
VITE_DASHBOARD_API_URL=https://sr-backend-api.takeleap.in
|
||||
VITE_AUDIT_API_URL=https://sr-audit.takeleap.in
|
||||
VITE_AUDIT_API_URL=http://localhost:7514
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
# VITE_DASHBOARD_API_URL=https://python.seekright.com
|
||||
# VITE_AUDIT_API_URL=https://node-audit.seekright.com
|
||||
VITE_DASHBOARD_API_URL=https://sr-backend-api.takeleap.in
|
||||
VITE_AUDIT_API_URL=https://sr-audit.takeleap.in
|
||||
VITE_AUDIT_API_URL=https://ms31kqw1-7514.inc1.devtunnels.ms
|
||||
|
||||
411
package-lock.json
generated
411
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"recharts": "^3.9.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -973,6 +974,32 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
@@ -1066,9 +1093,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1086,9 +1110,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1106,9 +1127,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1126,9 +1144,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1146,9 +1161,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1166,9 +1178,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1261,6 +1270,18 @@
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -1272,6 +1293,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -1343,6 +1427,12 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||
@@ -1897,6 +1987,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1914,6 +2125,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -2047,6 +2264,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -2251,6 +2478,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2586,6 +2819,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
|
||||
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -2612,6 +2855,15 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@@ -2888,9 +3140,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2912,9 +3161,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2936,9 +3182,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2960,9 +3203,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3411,6 +3651,29 @@
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||
@@ -3465,6 +3728,57 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
|
||||
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^11.1.8",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.2.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -3639,6 +3953,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -3776,6 +4096,37 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"recharts": "^3.9.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
|
||||
@@ -43,6 +43,7 @@ import { Login } from './pages/account/Login';
|
||||
import { Dashboard } from './pages/dashboard/Dashboard';
|
||||
import { GlobalFeed } from './pages/activity-feeds/GlobalFeed';
|
||||
import { AuditSession } from './pages/audit-session/AuditSession';
|
||||
import { AdminConsole } from './pages/admin/AdminConsole';
|
||||
import { ToastProvider } from './components/common/ToastProvider';
|
||||
|
||||
export default function App() {
|
||||
@@ -62,6 +63,9 @@ export default function App() {
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="activity-feeds" element={<GlobalFeed />} />
|
||||
<Route element={<ProtectedRoute requiredRole="ADMIN" />}>
|
||||
<Route path="admin" element={<AdminConsole />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
import { axiosClient } from './axiosClient';
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: string;
|
||||
user: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
org_id: number;
|
||||
db_name: string;
|
||||
role_id: number;
|
||||
role_name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RefreshResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: string;
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
org_id: number | string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
export const accountService = {
|
||||
login: async (username: string, password: string) => {
|
||||
// Replicating the environment.origin behavior if needed, currently hardcoded
|
||||
const body = {
|
||||
username,
|
||||
password,
|
||||
origin: "AUDITOR_REMOTE",
|
||||
};
|
||||
const response = await axiosClient.post('/api/dashboard/Master/login', body);
|
||||
return (response as any).responseData?.[0]?.records?.[0]; // matching the Angular extraction logic
|
||||
const response = await axiosClient.post('/api/audit/account/login', { username, password });
|
||||
return response as unknown as LoginResponse;
|
||||
},
|
||||
|
||||
refresh: async (refresh_token: string) => {
|
||||
const response = await axiosClient.post('/api/audit/account/refresh', { refresh_token });
|
||||
return response as unknown as RefreshResponse;
|
||||
},
|
||||
|
||||
logout: async (refresh_token: string) => {
|
||||
await axiosClient.post('/api/audit/account/logout', { refresh_token });
|
||||
},
|
||||
|
||||
getOrganizations: async () => {
|
||||
const response = await axiosClient.get('/api/audit/account/organizations');
|
||||
return response;
|
||||
},
|
||||
|
||||
// Orgs this user can work in: full list for ADMIN/MODERATOR, only
|
||||
// orgs-with-assigned-work for everyone else. Called during the login flow,
|
||||
// BEFORE the user is committed to the auth store - so the token must be
|
||||
// passed explicitly (the axios interceptor has nothing to attach yet).
|
||||
getMyOrganizations: async (accessToken: string): Promise<Organization[]> => {
|
||||
const response = await axiosClient.get('/api/audit/account/my-organizations', {
|
||||
headers: { 'x-access-token': accessToken },
|
||||
});
|
||||
return response as unknown as Organization[];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,13 +2,13 @@ import { axiosClient } from './axiosClient';
|
||||
|
||||
export const activityFeedsService = {
|
||||
getAssets: async () => {
|
||||
const response = await axiosClient.get('/api/dashboard/Master/asset_types');
|
||||
const response = await axiosClient.get('/api/audit/Master/asset_types');
|
||||
return response;
|
||||
},
|
||||
|
||||
getHistoryDates: async (isRectificationEnabled: boolean, siteIds?: string) => {
|
||||
getHistoryDates: async (isRectificationEnabled: boolean, siteIds?: string, auditedByMe = false) => {
|
||||
const response = await axiosClient.get('/api/audit/anomaly/get_history_dates', {
|
||||
params: { isRectificationEnabled, siteIds: siteIds || '' }
|
||||
params: { isRectificationEnabled, siteIds: siteIds || '', auditedByMe }
|
||||
});
|
||||
return response;
|
||||
},
|
||||
@@ -62,25 +62,70 @@ export const activityFeedsService = {
|
||||
return response;
|
||||
},
|
||||
|
||||
// Corrects a single record's asset classification. The backend resolves
|
||||
// the canonical asset_name from the central catalog and returns it.
|
||||
updateAnomalyAsset: async (id: number, assetId: number, isRectificationEnabled: boolean) => {
|
||||
const response = await axiosClient.put('/api/audit/anomaly/asset',
|
||||
{ id, asset_id: assetId },
|
||||
{ params: { isRectificationEnabled } }
|
||||
);
|
||||
return response as unknown as { id: number; asset_id: number; asset_name: string };
|
||||
},
|
||||
|
||||
// Called after normal (non-rectification) true anomaly audit
|
||||
updateAnomalyInDashboard: async (payload: { site_id: string, anomaly_id: number[] }) => {
|
||||
const response = await axiosClient.post('/api/audit/auditor/anomaly/update', payload);
|
||||
const response = await axiosClient.post('/api/audit/auditor/anomaly/submit', payload);
|
||||
return response;
|
||||
},
|
||||
|
||||
// Called after rectification true anomaly audit
|
||||
markAnomalyCompleted: async (payload: { anomaly_id: number[], site_id: number }) => {
|
||||
const response = await axiosClient.post('/api/audit/auditor/anomaly/completed', payload);
|
||||
const response = await axiosClient.post('/api/audit/auditor/anomaly/close', payload);
|
||||
return response;
|
||||
},
|
||||
|
||||
// Called after a rectification FALSE + 'true others' audit - the defect is
|
||||
// NOT fixed, so this re-opens/creates it in the work-order layer with
|
||||
// asset status DAMAGED. Backend only accepts rows already saved with
|
||||
// Audit_value = 'true others' (exact lowercase).
|
||||
rectifiedFalse: async (payload: { site_id: number; anomaly_id: number[] }) => {
|
||||
const response = await axiosClient.post('/api/audit/auditor/rectified/false', payload);
|
||||
return response;
|
||||
},
|
||||
|
||||
// Manual test-frame capture: extract the frame at `timestamp` (seconds) from
|
||||
// the record's video, store it as a new image, and repoint Frame_Test at it.
|
||||
// Returns { frame_test } - the new relative path to display/use.
|
||||
captureFrame: async (payload: { site_id: number | string; anomaly_id: number; table: 'audits' | 'rectification'; timestamp: number }) => {
|
||||
const response = await axiosClient.post('/api/audit/auditor/anomaly/capture-frame', payload);
|
||||
return response;
|
||||
},
|
||||
|
||||
getOrganizationSites: async (org_id: string) => {
|
||||
const response = await axiosClient.get('/api/dashboard/Master/site', {
|
||||
const response = await axiosClient.get('/api/audit/Master/site', {
|
||||
params: { org_id }
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
// Dashboard stats scoped to the logged-in user (org-wide for
|
||||
// ADMIN/MODERATOR): overall workload, today's activity, and the workload
|
||||
// split into batches of 50 in the same order the audit session walks.
|
||||
// sortBy/sortDir keep the batch numbering aligned with the feed/session
|
||||
// order when a custom sort is active (backend mirrors the same whitelist).
|
||||
getUserStats: async (isRectificationEnabled: boolean, sortBy?: string, sortDir?: string) => {
|
||||
const response = await axiosClient.get('/api/audit/anomaly/user-stats', {
|
||||
params: { isRectificationEnabled, sortBy: sortBy || '', sortDir: sortDir || '' }
|
||||
});
|
||||
return response as unknown as {
|
||||
scoped: boolean;
|
||||
batchSize: number;
|
||||
overall: { total: number; audited: number; pending: number; anomaliesFound: number };
|
||||
today: { audited: number; anomaliesFound: number };
|
||||
batches: { batch: number; total: number; audited: number; pending: number; anomaliesFound: number }[];
|
||||
};
|
||||
},
|
||||
|
||||
getFalseAnomaly: async (params: any) => {
|
||||
const response = await axiosClient.get('/api/audit/asset/get-false-Anomaly', { params });
|
||||
return response;
|
||||
|
||||
388
src/api/adminService.ts
Normal file
388
src/api/adminService.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { axiosClient } from './axiosClient';
|
||||
|
||||
export interface AdminUser {
|
||||
user_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
org_id: number | null;
|
||||
db_name: string;
|
||||
role_id: number;
|
||||
role_name: string;
|
||||
disabled: boolean;
|
||||
last_login: string | null;
|
||||
created_on: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
role_name: string;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
login_at: string;
|
||||
logout_at: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
role_id: number;
|
||||
org_id: number;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserPayload {
|
||||
username?: string;
|
||||
role_id?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AllocationRecord {
|
||||
id: number;
|
||||
Asset: string | null;
|
||||
Plaza: string | null;
|
||||
Chainage: string | null;
|
||||
video_date: string | null;
|
||||
assigned_user_id: number | null;
|
||||
assigned_username: string | null;
|
||||
}
|
||||
|
||||
export interface AllocationRecordsResult {
|
||||
records: AllocationRecord[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface AllocationFilters {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
assetId?: number | string;
|
||||
siteId?: number | string;
|
||||
}
|
||||
|
||||
export interface AuditHistoryUserCount {
|
||||
user_id: number;
|
||||
username: string;
|
||||
assigned: number;
|
||||
// Completions within the window (Audited_on) - a throughput number that
|
||||
// includes old backlog, so it CAN exceed `assigned`.
|
||||
completed: number;
|
||||
// Share of their own windowed assigned queue that is done (0-100, never
|
||||
// above 100). null when nothing was assigned in this window (they may
|
||||
// still have completed backlog items - that's not "0% efficient").
|
||||
efficiency: number | null;
|
||||
}
|
||||
|
||||
export interface AuditHistorySummary {
|
||||
total: number;
|
||||
audited: number;
|
||||
notAudited: number;
|
||||
byUser: AuditHistoryUserCount[];
|
||||
}
|
||||
|
||||
export interface AuditHistoryRecord {
|
||||
id: number;
|
||||
Asset: string | null;
|
||||
Plaza: string | null;
|
||||
Chainage: string | null;
|
||||
video_date: string | null;
|
||||
IsAudited: number;
|
||||
Audited_on: string | null;
|
||||
audited_user_id: number | null;
|
||||
audited_username: string | null;
|
||||
}
|
||||
|
||||
export interface AuditHistoryRecordsResult {
|
||||
records: AuditHistoryRecord[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DevTicket {
|
||||
id: number;
|
||||
anomaly_id: number;
|
||||
org_db_name: string | null;
|
||||
asset: string | null;
|
||||
plaza: string | null;
|
||||
dev_handle: string;
|
||||
git_username: string | null;
|
||||
reporter: string | null;
|
||||
message_text: string;
|
||||
channel: string;
|
||||
duplicate_of_id: number | null;
|
||||
rocketchat_sent: boolean;
|
||||
rocketchat_error: string | null;
|
||||
rocketchat_sent_at: string | null;
|
||||
git_status: 'pending' | 'success' | 'failed';
|
||||
git_issue_url: string | null;
|
||||
git_issue_number: number | null;
|
||||
git_error: string | null;
|
||||
retry_count: number;
|
||||
last_retry_at: string | null;
|
||||
created_at: string;
|
||||
// Which org table the record lives in; null on legacy tickets.
|
||||
record_type: 'audits' | 'rectification' | null;
|
||||
// Unified resolution stamp - set by the admin Resolve button or by the
|
||||
// git-state cron when the Gitea issue is closed.
|
||||
resolved_at: string | null;
|
||||
resolved_by: string | null;
|
||||
// Live record state, attached by the list endpoint.
|
||||
record_status: 'under_review' | 'resolved' | 'audited' | 'deleted' | 'unknown';
|
||||
}
|
||||
|
||||
// Full record row as returned by /admin/dev-tickets/record-details.
|
||||
export interface TicketRecordDetails {
|
||||
ticket: DevTicket;
|
||||
recordType: 'audits' | 'rectification';
|
||||
record: {
|
||||
id: number;
|
||||
Asset: string | null;
|
||||
asset_id: number | null;
|
||||
Plaza: string | null;
|
||||
Chainage: string | null;
|
||||
Side: string | null;
|
||||
Pos: string | null;
|
||||
video_date: string | null;
|
||||
Created_on: string | null;
|
||||
Frame_Test: string | null;
|
||||
Frame_Master: string | null;
|
||||
Extra_Image: string | null;
|
||||
video_url: string | null;
|
||||
Comments: string | null;
|
||||
IsAudited: number;
|
||||
Audit_status: number | null;
|
||||
Audit_value: string | null;
|
||||
under_review: number | null;
|
||||
deleted: number;
|
||||
road?: string | null;
|
||||
updated_user_name: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevTicketsResult {
|
||||
records: DevTicket[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ClientOverviewSite {
|
||||
site_id: number;
|
||||
site_name: string;
|
||||
total: number;
|
||||
audited: number;
|
||||
deleted: number;
|
||||
nonAudited: number;
|
||||
}
|
||||
|
||||
export interface ClientOverviewOrg {
|
||||
org_id: number;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
total: number;
|
||||
audited: number;
|
||||
deleted: number;
|
||||
nonAudited: number;
|
||||
sites: ClientOverviewSite[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ClientsOverviewResult {
|
||||
type: 'audits' | 'rectification';
|
||||
clients: ClientOverviewOrg[];
|
||||
}
|
||||
|
||||
export interface MissingMediaRecord {
|
||||
id: number;
|
||||
Asset: string | null;
|
||||
Plaza: string | null;
|
||||
site_id: number;
|
||||
video_date: string | null;
|
||||
frame_available: boolean | null;
|
||||
video_available: boolean | null;
|
||||
has_video_declared: boolean;
|
||||
media_checked_at: string | null;
|
||||
media_check_count: number;
|
||||
// human-readable blocker: "not checked yet" | "image" | "video" | "image + video"
|
||||
missing: string;
|
||||
}
|
||||
|
||||
export interface MissingMediaSummary {
|
||||
total: number;
|
||||
neverChecked: number;
|
||||
// Independent, inclusive counts: missingImage/missingVideo each include
|
||||
// records missing both; missingBoth is the overlap.
|
||||
missingImage: number;
|
||||
missingVideo: number;
|
||||
missingBoth: number;
|
||||
gaveUp: number;
|
||||
}
|
||||
|
||||
export type MissingMediaFilter = 'image' | 'video' | 'both';
|
||||
|
||||
export interface MissingMediaResult {
|
||||
records: MissingMediaRecord[];
|
||||
total: number;
|
||||
summary: MissingMediaSummary;
|
||||
}
|
||||
|
||||
|
||||
export const adminService = {
|
||||
getUsers: async (): Promise<AdminUser[]> => {
|
||||
return (await axiosClient.get('/api/audit/admin/users')) as unknown as AdminUser[];
|
||||
},
|
||||
|
||||
getRoles: async (): Promise<Role[]> => {
|
||||
return (await axiosClient.get('/api/audit/admin/roles')) as unknown as Role[];
|
||||
},
|
||||
|
||||
createUser: async (payload: CreateUserPayload): Promise<{ user_id: number }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/users', payload)) as unknown as { user_id: number };
|
||||
},
|
||||
|
||||
updateUser: async (userId: number, payload: UpdateUserPayload): Promise<void> => {
|
||||
await axiosClient.patch(`/api/audit/admin/users/${userId}`, payload);
|
||||
},
|
||||
|
||||
getSessions: async (params?: { username?: string; limit?: number; offset?: number }): Promise<UserSession[]> => {
|
||||
return (await axiosClient.get('/api/audit/admin/sessions', { params })) as unknown as UserSession[];
|
||||
},
|
||||
|
||||
getAllocationRecords: async (params: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
sort: 'oldest' | 'newest';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
// number = that user's queue; the literal string 'null' = unassigned only
|
||||
assignedUserId?: number | 'null';
|
||||
} & AllocationFilters): Promise<AllocationRecordsResult> => {
|
||||
return (await axiosClient.get('/api/audit/admin/allocation/records', { params })) as unknown as AllocationRecordsResult;
|
||||
},
|
||||
|
||||
// Multi-user assign: a count is the TOTAL, split equally across user_ids
|
||||
// (remainder to the first users); count-mode only ever touches unassigned
|
||||
// records. Explicit ids are round-robined across user_ids and may
|
||||
// deliberately overwrite existing assignments.
|
||||
assignAllocation: async (payload: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
ids?: number[];
|
||||
count?: number;
|
||||
sort?: 'oldest' | 'newest';
|
||||
user_ids: number[];
|
||||
} & AllocationFilters): Promise<{ assigned: number; perUser: { user_id: number; assigned: number }[] }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/allocation/assign', payload)) as unknown as {
|
||||
assigned: number; perUser: { user_id: number; assigned: number }[];
|
||||
};
|
||||
},
|
||||
|
||||
// Revert (toUserId: null) or transfer a user's PENDING allocation; completed
|
||||
// records keep their assignment so history stats stay truthful.
|
||||
reassignAllocation: async (payload: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
fromUserId?: number;
|
||||
toUserId: number | null;
|
||||
ids?: number[];
|
||||
}): Promise<{ moved: number }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/allocation/reassign', payload)) as unknown as { moved: number };
|
||||
},
|
||||
|
||||
getAuditHistorySummary: async (params: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
from?: string;
|
||||
to?: string;
|
||||
}): Promise<AuditHistorySummary> => {
|
||||
return (await axiosClient.get('/api/audit/admin/audit-history/summary', { params })) as unknown as AuditHistorySummary;
|
||||
},
|
||||
|
||||
getAuditHistoryUserRecords: async (params: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
userId: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<AuditHistoryRecordsResult> => {
|
||||
return (await axiosClient.get('/api/audit/admin/audit-history/records', { params })) as unknown as AuditHistoryRecordsResult;
|
||||
},
|
||||
|
||||
getDevTickets: async (params: {
|
||||
search?: string;
|
||||
gitStatus?: 'pending' | 'success' | 'failed';
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<DevTicketsResult> => {
|
||||
return (await axiosClient.get('/api/audit/admin/dev-tickets', { params })) as unknown as DevTicketsResult;
|
||||
},
|
||||
|
||||
// Keyed by dev_ticket id (not Gitea issue number) - the backend resolves
|
||||
// each ticket's own stored git_issue_url, so this is correct even for
|
||||
// tickets filed before a repo migration.
|
||||
getIssueStates: async (ticketIds: number[]): Promise<Record<number, 'open' | 'closed' | null>> => {
|
||||
const result = (await axiosClient.get('/api/audit/admin/dev-tickets/issue-states', {
|
||||
params: { ticketIds: ticketIds.join(',') },
|
||||
})) as unknown as { states: Record<number, 'open' | 'closed' | null> };
|
||||
return result.states;
|
||||
},
|
||||
|
||||
// Pending records hidden from all auditing/allocation surfaces because
|
||||
// their media isn't confirmed on the CDN yet.
|
||||
getMissingMedia: async (params: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
missing?: MissingMediaFilter;
|
||||
}): Promise<MissingMediaResult> => {
|
||||
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
|
||||
},
|
||||
|
||||
// Unified resolve: returns the quarantined record to the auditors' feed
|
||||
// and (best-effort) closes the linked Gitea issue.
|
||||
resolveTicket: async (ticketId: number): Promise<{ recordReleased: boolean; issueClosed: boolean }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/dev-tickets/resolve', { ticketId })) as unknown as {
|
||||
recordReleased: boolean; issueClosed: boolean;
|
||||
};
|
||||
},
|
||||
|
||||
// Complete record + ticket details for the admin popup.
|
||||
getTicketRecordDetails: async (ticketId: number): Promise<TicketRecordDetails> => {
|
||||
return (await axiosClient.get('/api/audit/admin/dev-tickets/record-details', {
|
||||
params: { ticketId },
|
||||
})) as unknown as TicketRecordDetails;
|
||||
},
|
||||
|
||||
// Immediate CDN re-verify for up to 50 records (also resets the cron's
|
||||
// give-up counter for them).
|
||||
recheckMedia: async (payload: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
ids: number[];
|
||||
}): Promise<{ checked: number; ready: number; missing: number; skipped: number }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/media-status/recheck', payload)) as unknown as {
|
||||
checked: number; ready: number; missing: number; skipped: number;
|
||||
};
|
||||
},
|
||||
|
||||
getClientsOverview: async (params: {
|
||||
type: 'audits' | 'rectification';
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
assetIds?: number[];
|
||||
}): Promise<ClientsOverviewResult> => {
|
||||
const { assetIds, ...rest } = params;
|
||||
return (await axiosClient.get('/api/audit/admin/clients-overview', {
|
||||
params: { ...rest, assetIds: assetIds && assetIds.length > 0 ? assetIds.join(',') : undefined },
|
||||
})) as unknown as ClientsOverviewResult;
|
||||
},
|
||||
};
|
||||
30
src/api/auditSummaryService.ts
Normal file
30
src/api/auditSummaryService.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { axiosClient } from './axiosClient';
|
||||
|
||||
export interface AuditSummaryParams {
|
||||
dbName: string;
|
||||
site_id: string;
|
||||
isRectificationEnabled?: boolean;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
}
|
||||
|
||||
export interface AuditSummaryResponse {
|
||||
db_name: string;
|
||||
site_id: number[];
|
||||
table: string;
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
counts: {
|
||||
pending: number;
|
||||
deleted: number;
|
||||
audited: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const auditSummaryService = {
|
||||
getSummary: async (params: AuditSummaryParams): Promise<AuditSummaryResponse> => {
|
||||
const response = await axiosClient.get('/api/audit/audit-summary', { params });
|
||||
return response as any;
|
||||
},
|
||||
};
|
||||
@@ -16,30 +16,17 @@ export const axiosClient = axios.create({
|
||||
// Request Interceptor: Attach token if available
|
||||
axiosClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// Rocket.Chat webhook: skip auth headers, skip dbName, let Vite proxy handle in dev
|
||||
if (config.url?.startsWith('/api/rocketchat')) {
|
||||
if (import.meta.env.DEV) {
|
||||
// Leave URL as-is — Vite proxy rewrites /api/rocketchat → https://text.seekright.com
|
||||
return config;
|
||||
}
|
||||
// Production: rewrite URL directly (no Vite proxy)
|
||||
const rcUrl = import.meta.env.VITE_ROCKETCHAT_URL || 'https://text.seekright.com';
|
||||
config.url = config.url.replace('/api/rocketchat', rcUrl);
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
const user = useAuthStore.getState().user;
|
||||
if (user?.access_token) {
|
||||
config.headers['x-access-token'] = user.access_token;
|
||||
}
|
||||
if (user?.db_name) {
|
||||
if (user?.db_name && !config.url?.includes('asset_types') && !config.params?.dbName) {
|
||||
config.params = { ...config.params, dbName: user.db_name };
|
||||
}
|
||||
|
||||
if (config.url?.startsWith('/api/dashboard')) {
|
||||
const dashboardUrl = import.meta.env.VITE_DASHBOARD_API_URL || 'https://sr-backend-api.takeleap.in';
|
||||
config.url = config.url.replace('/api/dashboard', dashboardUrl);
|
||||
} else if (config.url?.startsWith('/api/audit')) {
|
||||
if (config.url?.startsWith('/api/audit')) {
|
||||
const auditUrl = import.meta.env.VITE_AUDIT_API_URL || 'https://sr-audit.takeleap.in';
|
||||
config.url = config.url.replace('/api/audit', auditUrl);
|
||||
}
|
||||
@@ -49,6 +36,22 @@ axiosClient.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Coalesces concurrent 401s behind a single in-flight refresh call. Required
|
||||
// (not just an optimization): refresh tokens rotate on every use, so two
|
||||
// simultaneous refresh calls would race and the second would fail against an
|
||||
// already-consumed token.
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
const subscribeTokenRefresh = (cb: (token: string) => void) => {
|
||||
refreshSubscribers.push(cb);
|
||||
};
|
||||
|
||||
const onRefreshed = (token: string) => {
|
||||
refreshSubscribers.forEach((cb) => cb(token));
|
||||
refreshSubscribers = [];
|
||||
};
|
||||
|
||||
// Response Interceptor: Global Error Handling (like Angular's HttpService)
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
@@ -59,6 +62,59 @@ axiosClient.interceptors.response.use(
|
||||
}
|
||||
|
||||
const { status, data } = error.response;
|
||||
const originalRequest = error.config;
|
||||
|
||||
const isRefreshCall = originalRequest?.url?.includes('/account/refresh');
|
||||
const isLoginCall = originalRequest?.url?.includes('/account/login');
|
||||
|
||||
// A 401 from login itself just means "wrong credentials" - let the caller
|
||||
// (Login.tsx) handle it directly. It must not trigger the
|
||||
// already-logged-in-session-expired logic below (which force-redirects
|
||||
// to /login), since there's no session to expire yet.
|
||||
if (isLoginCall) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (status === 401 && !isRefreshCall && !originalRequest?._retry) {
|
||||
const user = useAuthStore.getState().user;
|
||||
|
||||
if (!user?.refresh_token) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((newToken: string) => {
|
||||
originalRequest.headers['x-access-token'] = newToken;
|
||||
resolve(axiosClient(originalRequest));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
|
||||
return axiosClient
|
||||
.post('/api/audit/account/refresh', { refresh_token: user.refresh_token })
|
||||
.then((refreshResponse: any) => {
|
||||
const { access_token, refresh_token } = refreshResponse;
|
||||
useAuthStore.setState({ user: { ...user, access_token, refresh_token } });
|
||||
isRefreshing = false;
|
||||
onRefreshed(access_token);
|
||||
originalRequest.headers['x-access-token'] = access_token;
|
||||
return axiosClient(originalRequest);
|
||||
})
|
||||
.catch((refreshError) => {
|
||||
isRefreshing = false;
|
||||
refreshSubscribers = [];
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 400) {
|
||||
const message = data?.responseStatus?.responseMessage || data?.errorMessage || 'Something went wrong. Please try again.';
|
||||
|
||||
219
src/components/common/AssetMultiSelect.tsx
Normal file
219
src/components/common/AssetMultiSelect.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useState, useMemo, useCallback, memo } from 'react';
|
||||
import {
|
||||
Box, Button, Popover, TextField, Checkbox, FormControlLabel, Typography,
|
||||
InputAdornment, Divider,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
|
||||
interface AssetMultiSelectProps {
|
||||
value: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
// Mirrors the grouped asset picker in FeedFilters.tsx (search + per-category
|
||||
// select-all + counts) as a standalone controlled component, so it can be
|
||||
// dropped into an inline filter bar instead of a full-page dialog.
|
||||
const AssetRow = memo(({
|
||||
assetId, assetName, checked, onToggle,
|
||||
}: {
|
||||
assetId: number;
|
||||
assetName: string;
|
||||
checked: boolean;
|
||||
onToggle: (id: number) => void;
|
||||
}) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(assetId)}
|
||||
sx={{ color: '#475569', '&.Mui-checked': { color: '#3b82f6' }, py: 0.3 }}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.72rem' }}>{assetName}</Typography>}
|
||||
/>
|
||||
));
|
||||
|
||||
const GroupHeader = memo(({
|
||||
typeName, assetCount, allGroupSelected, someGroupSelected, onToggle,
|
||||
}: {
|
||||
typeName: string;
|
||||
assetCount: number;
|
||||
allGroupSelected: boolean;
|
||||
someGroupSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={allGroupSelected}
|
||||
indeterminate={someGroupSelected}
|
||||
onChange={onToggle}
|
||||
size="small"
|
||||
sx={{ color: '#64748b', '&.Mui-checked': { color: '#60a5fa' }, '&.MuiCheckbox-indeterminate': { color: '#60a5fa' } }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#60a5fa', textTransform: 'uppercase', fontSize: '0.7rem', letterSpacing: 1 }}>
|
||||
{typeName} ({assetCount})
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
));
|
||||
|
||||
export const AssetMultiSelect: React.FC<AssetMultiSelectProps> = ({ value, onChange }) => {
|
||||
const { assetTypes } = useFeedStore();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [draftSet, setDraftSet] = useState<Set<number>>(() => new Set(value));
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
|
||||
setDraftSet(new Set(value));
|
||||
setSearch('');
|
||||
setAnchorEl(e.currentTarget);
|
||||
};
|
||||
|
||||
const commitAndClose = () => {
|
||||
onChange(Array.from(draftSet));
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const allAssetIds = useMemo(
|
||||
() => assetTypes.flatMap((t: any) => t.assets.map((a: any) => a.asset_id as number)),
|
||||
[assetTypes],
|
||||
);
|
||||
|
||||
const filteredAssetTypes = useMemo(() => {
|
||||
if (!search.trim()) return assetTypes;
|
||||
const q = search.toLowerCase();
|
||||
return assetTypes
|
||||
.map((type: any) => ({
|
||||
...type,
|
||||
assets: type.assets.filter((a: any) => a.asset_name.toLowerCase().includes(q)),
|
||||
}))
|
||||
.filter((type: any) => type.assets.length > 0);
|
||||
}, [assetTypes, search]);
|
||||
|
||||
const allSelected = allAssetIds.length > 0 && allAssetIds.every((id) => draftSet.has(id));
|
||||
const someSelected = draftSet.size > 0 && !allSelected;
|
||||
|
||||
const handleToggleAsset = useCallback((assetId: number) => {
|
||||
setDraftSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(assetId)) next.delete(assetId); else next.add(assetId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleAll = useCallback(() => {
|
||||
setDraftSet((prev) => (prev.size === allAssetIds.length ? new Set() : new Set(allAssetIds)));
|
||||
}, [allAssetIds]);
|
||||
|
||||
const makeGroupToggle = useCallback((groupIds: number[]) => () => {
|
||||
setDraftSet((prev) => {
|
||||
const allIn = groupIds.every((id) => prev.has(id));
|
||||
const next = new Set(prev);
|
||||
if (allIn) groupIds.forEach((id) => next.delete(id));
|
||||
else groupIds.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleOpen}
|
||||
sx={{ borderColor: '#334155', color: value.length > 0 ? '#f8fafc' : '#94a3b8', textTransform: 'none', fontWeight: 'bold', bgcolor: 'rgba(255,255,255,0.05)' }}
|
||||
>
|
||||
{value.length > 0 ? `Assets (${value.length})` : 'All Assets'}
|
||||
</Button>
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={commitAndClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
slotProps={{ paper: { sx: { bgcolor: '#0f172a', border: '1px solid #1e293b', color: '#f8fafc', width: 420 } } }}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<TextField
|
||||
placeholder="Search assets..."
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ mb: 1.5, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 1, input: { color: '#f8fafc' }, '& fieldset': { borderColor: '#334155' } }}
|
||||
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#64748b' }} fontSize="small" /></InputAdornment> } }}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={handleToggleAll}
|
||||
size="small"
|
||||
sx={{ color: '#64748b', '&.Mui-checked': { color: '#3b82f6' }, '&.MuiCheckbox-indeterminate': { color: '#3b82f6' } }}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2" sx={{ color: '#94a3b8', fontWeight: 'bold' }}>Select All ({allAssetIds.length})</Typography>}
|
||||
/>
|
||||
|
||||
<Divider sx={{ borderColor: '#1e293b', my: 1 }} />
|
||||
|
||||
<Box sx={{ maxHeight: 320, overflowY: 'auto', pr: 1 }}>
|
||||
{filteredAssetTypes.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>No assets match your search.</Typography>
|
||||
) : (
|
||||
filteredAssetTypes.map((type: any) => {
|
||||
const groupIds: number[] = type.assets.map((a: any) => a.asset_id);
|
||||
const allGroupSelected = groupIds.every((id) => draftSet.has(id));
|
||||
const someGroupSelected = groupIds.some((id) => draftSet.has(id)) && !allGroupSelected;
|
||||
|
||||
return (
|
||||
<Box key={type.type_id ?? 'uncategorized'} sx={{ mb: 1.5 }}>
|
||||
<GroupHeader
|
||||
typeName={type.type_name || 'Uncategorized'}
|
||||
assetCount={type.assets.length}
|
||||
allGroupSelected={allGroupSelected}
|
||||
someGroupSelected={someGroupSelected}
|
||||
onToggle={makeGroupToggle(groupIds)}
|
||||
/>
|
||||
<Box sx={{ pl: 3 }}>
|
||||
{type.assets.map((asset: any) => (
|
||||
<AssetRow
|
||||
key={asset.asset_id}
|
||||
assetId={asset.asset_id}
|
||||
assetName={asset.asset_name.replace(/_/g, ' ')}
|
||||
checked={draftSet.has(asset.asset_id)}
|
||||
onToggle={handleToggleAsset}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: '#1e293b', my: 1 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button size="small" onClick={() => setDraftSet(new Set())} sx={{ color: '#94a3b8', textTransform: 'none' }}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button size="small" variant="contained" onClick={commitAndClose} sx={{ bgcolor: '#3b82f6', textTransform: 'none', '&:hover': { bgcolor: '#2563eb' } }}>
|
||||
Done
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
|
||||
interface BulkDeleteDialogProps {
|
||||
@@ -17,8 +18,9 @@ interface BulkDeleteDialogProps {
|
||||
const REASONS = ['Low Light', 'Bad Image', 'Wrong Algorithm', 'Out of Range'];
|
||||
|
||||
export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClose }) => {
|
||||
const { assetTypes, selectedSite, sites } = useFeedStore();
|
||||
const { assetTypes, selectedSite, sites, isRectificationEnabled, tabs, selectedTabIndex, updateTab, dashboardCache, setDashboardCache } = useFeedStore();
|
||||
const { user } = useAuthStore();
|
||||
const showToast = useToastStore((s) => s.showToast);
|
||||
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [tillDate, setTillDate] = useState('');
|
||||
@@ -83,11 +85,11 @@ export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClos
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!fromDate || !tillDate) {
|
||||
alert("Please select both From and Till dates.");
|
||||
showToast("Please select both From and Till dates.", "warning");
|
||||
return;
|
||||
}
|
||||
if (selectedAssets.size === 0) {
|
||||
alert("Please select at least one asset.");
|
||||
showToast("Please select at least one asset.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,20 +110,33 @@ export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClos
|
||||
site_id: siteIds,
|
||||
fromDate: fromDate,
|
||||
toDate: tillDate,
|
||||
user_id: Number(user?.user_id),
|
||||
audit_value: reason,
|
||||
isBulkDelete: true,
|
||||
audit_type: auditType
|
||||
audit_type: auditType,
|
||||
isRectificationEnabled,
|
||||
};
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await activityFeedsService.bulkDelete(payload);
|
||||
alert("Bulk delete successful!");
|
||||
const res: any = await activityFeedsService.bulkDelete(payload);
|
||||
const affected = res?.data?.affectedRows ?? res?.data?.rowCount;
|
||||
// Drop the deleted records from view: clear the active tab's cache so
|
||||
// GlobalFeed refetches (its guard refetches when anomalies is empty),
|
||||
// and invalidate dashboard stats.
|
||||
if (selectedTabIndex >= 0 && tabs[selectedTabIndex]) {
|
||||
updateTab(selectedTabIndex, { anomalies: [], anomaliesCopy: [], filters: {} });
|
||||
}
|
||||
setDashboardCache({ ...dashboardCache, stats: null });
|
||||
showToast(
|
||||
typeof affected === 'number'
|
||||
? `Bulk delete successful — ${affected} record(s) removed.`
|
||||
: 'Bulk delete successful.',
|
||||
'success'
|
||||
);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
console.error("Bulk delete failed", err);
|
||||
alert("Bulk delete failed. " + (err?.response?.data?.message || err.message || ""));
|
||||
showToast("Bulk delete failed. " + (err?.response?.data?.message || err.message || ""), "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -118,18 +118,21 @@ export const TopFilterBar: React.FC = () => {
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [loadingRectHistory, setLoadingRectHistory] = useState(false);
|
||||
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
|
||||
const [selectedHistoryDate, setSelectedHistoryDate] = useState('2026-06-12');
|
||||
const [historyDropdownValue, setHistoryDropdownValue] = useState('');
|
||||
// One dialog serves both History surfaces; the mode decides which table
|
||||
// (audits vs rectification) the tab it opens will query.
|
||||
const [historyDialogMode, setHistoryDialogMode] = useState<'audits' | 'rect'>('rect');
|
||||
const [historyFrom, setHistoryFrom] = useState('');
|
||||
const [historyTill, setHistoryTill] = useState('');
|
||||
|
||||
// Fetch normal history dates (isRectificationEnabled=false) for the History dropdown
|
||||
// Rectification history (isRectificationEnabled=true) is fetched separately by the RECTIFICATION HISTORY button
|
||||
// Fetch normal history dates (isRectificationEnabled=false) for the audits-mode History dialog chips
|
||||
// Rectification dates (isRectificationEnabled=true) are fetched when the dialog opens in rect mode
|
||||
useEffect(() => {
|
||||
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
||||
const fetchDates = async () => {
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const siteIds = String(selectedSite.site_id);
|
||||
const dates: any = await activityFeedsService.getHistoryDates(false, siteIds);
|
||||
const dates: any = await activityFeedsService.getHistoryDates(false, siteIds, true);
|
||||
setHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
|
||||
} catch (err) {
|
||||
console.error("Failed to load history dates", err);
|
||||
@@ -140,50 +143,41 @@ export const TopFilterBar: React.FC = () => {
|
||||
fetchDates();
|
||||
}, [selectedSite?.site_id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleHistoryClick = async () => {
|
||||
const openHistoryDialog = async (mode: 'audits' | 'rect') => {
|
||||
setHistoryDialogMode(mode);
|
||||
setHistoryFrom('');
|
||||
setHistoryTill('');
|
||||
setIsHistoryDialogOpen(true);
|
||||
if (mode === 'rect') {
|
||||
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
||||
setLoadingRectHistory(true);
|
||||
try {
|
||||
const siteIds = String(selectedSite.site_id);
|
||||
const dates: any = await activityFeedsService.getHistoryDates(true, siteIds);
|
||||
const dates: any = await activityFeedsService.getHistoryDates(true, siteIds, true);
|
||||
setRectHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
|
||||
} catch (err) {
|
||||
console.error("Failed to load rectification history dates", err);
|
||||
} finally {
|
||||
setLoadingRectHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Used by RECTIFICATION HISTORY dialog — isRectificationTab: true → fetches with isRectificationEnabled=true
|
||||
const handleSelectDate = (date: string) => {
|
||||
const existingIndex = tabs.findIndex(t => t.date === date && t.isRectificationTab);
|
||||
if (existingIndex !== -1) {
|
||||
setSelectedTabIndex(existingIndex);
|
||||
} else {
|
||||
addTab({
|
||||
date: date,
|
||||
isRectificationTab: true,
|
||||
totalAnomalyCount: 0,
|
||||
anomalies: [],
|
||||
anomaliesCopy: [],
|
||||
selectedAnomalyIndex: 0,
|
||||
filters: {},
|
||||
page: 0,
|
||||
rowsPerPage: 20
|
||||
});
|
||||
}
|
||||
// 'audits' chips come from the historyDates effect (refetched per site).
|
||||
};
|
||||
|
||||
// Used by History dropdown — isRectificationTab: false → fetches with isRectificationEnabled=false
|
||||
const handleHistoryDateSelect = (date: string) => {
|
||||
const existingIndex = tabs.findIndex(t => t.date === date && !t.isRectificationTab);
|
||||
// Opens (or focuses) a History tab: records audited BY the logged-in user
|
||||
// (auditedByMe) on one date or a From-Till range. The range is encoded in
|
||||
// the tab's `date` as "from_till" - the backend parses it; single day =
|
||||
// plain date. Audits and rectification stay separate via isRectificationTab.
|
||||
const openHistoryTab = (isRect: boolean, from: string, till: string) => {
|
||||
const dateKey = from === till ? from : `${from}_${till}`;
|
||||
const existingIndex = tabs.findIndex(t => t.date === dateKey && !!t.isRectificationTab === isRect);
|
||||
if (existingIndex !== -1) {
|
||||
setSelectedTabIndex(existingIndex);
|
||||
} else {
|
||||
addTab({
|
||||
date: date,
|
||||
isRectificationTab: false,
|
||||
date: dateKey,
|
||||
isRectificationTab: isRect,
|
||||
auditedByMe: true,
|
||||
totalAnomalyCount: 0,
|
||||
anomalies: [],
|
||||
anomaliesCopy: [],
|
||||
@@ -196,8 +190,12 @@ export const TopFilterBar: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleViewHistory = () => {
|
||||
if (!selectedHistoryDate) return;
|
||||
handleSelectDate(selectedHistoryDate);
|
||||
if (!historyFrom || !historyTill) return;
|
||||
// Tolerate a reversed range instead of erroring.
|
||||
const [from, till] = historyFrom <= historyTill
|
||||
? [historyFrom, historyTill]
|
||||
: [historyTill, historyFrom];
|
||||
openHistoryTab(historyDialogMode === 'rect', from, till);
|
||||
setIsHistoryDialogOpen(false);
|
||||
};
|
||||
|
||||
@@ -238,14 +236,6 @@ export const TopFilterBar: React.FC = () => {
|
||||
>
|
||||
FILTERS
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<HistoryIcon />}
|
||||
onClick={handleHistoryClick}
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, borderRadius: 2, textTransform: 'none', fontWeight: 'bold', boxShadow: 'none' }}
|
||||
>
|
||||
RECTIFICATION HISTORY
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
@@ -254,46 +244,20 @@ export const TopFilterBar: React.FC = () => {
|
||||
BULK DELETE
|
||||
</Button>
|
||||
|
||||
{/* History dropdown — only visible when a specific site is selected */}
|
||||
{localSiteId !== 'All Sites' && <FormControl size="small" sx={{ bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2, minWidth: 160 }}>
|
||||
<Select
|
||||
value={historyDropdownValue}
|
||||
displayEmpty
|
||||
onChange={(e) => {
|
||||
const date = e.target.value as string;
|
||||
if (date) {
|
||||
handleHistoryDateSelect(date);
|
||||
setHistoryDropdownValue('');
|
||||
}
|
||||
}}
|
||||
renderValue={(val) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{loadingHistory && <CircularProgress size={12} sx={{ color: '#fff' }} />}
|
||||
<span>{val || 'History'}</span>
|
||||
</Box>
|
||||
)}
|
||||
sx={{ '& .MuiSelect-select': { py: 1, px: 2 }, border: 'none', '& fieldset': { border: 'none' }, fontWeight: 'bold', color: '#fff', '& .MuiSvgIcon-root': { color: '#fff' } }}
|
||||
MenuProps={{
|
||||
slotProps: {
|
||||
paper: {
|
||||
sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', maxHeight: 300 }
|
||||
}
|
||||
}
|
||||
}}
|
||||
{/* History — one button for both modes: follows the active mode
|
||||
(audits vs rectification) so the dialog and the tab it opens query
|
||||
the right table. Only visible when a specific site is selected
|
||||
(the audited-dates lookup is per-site). */}
|
||||
{localSiteId !== 'All Sites' && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<HistoryIcon />}
|
||||
onClick={() => openHistoryDialog(isRectActive ? 'rect' : 'audits')}
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, borderRadius: 2, textTransform: 'none', fontWeight: 'bold', boxShadow: 'none' }}
|
||||
>
|
||||
<MenuItem disabled value="">
|
||||
<em style={{ color: '#64748b' }}>{loadingHistory ? 'Loading...' : historyDates.length === 0 ? 'No dates available' : 'Select a date'}</em>
|
||||
</MenuItem>
|
||||
{historyDates.map((d) => {
|
||||
const dateStr = d.audited_on ? d.audited_on.split(' ')[0] : d.audited_on;
|
||||
return (
|
||||
<MenuItem key={dateStr} value={dateStr} sx={{ '&:hover': { bgcolor: '#1e293b' } }}>
|
||||
{dateStr}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>}
|
||||
HISTORY
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={isHistoryDialogOpen}
|
||||
@@ -311,19 +275,23 @@ export const TopFilterBar: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 'bold', pb: 1 }}>Rectification History</DialogTitle>
|
||||
<DialogTitle sx={{ fontWeight: 'bold', pb: 1 }}>
|
||||
{historyDialogMode === 'rect' ? 'Rectification History' : 'Audit History'}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ py: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 2 }}>
|
||||
Enter a date (YYYY-MM-DD) or pick one from the list to view historical rectification records.
|
||||
Pick a date or a From–Till range to view the {historyDialogMode === 'rect' ? 'rectification' : 'audit'} records you audited. A quick-select chip fills both fields with one day.
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', mb: 1 }}>History Date</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
{([['From', historyFrom, setHistoryFrom], ['Till', historyTill, setHistoryTill]] as const).map(([label, value, setter]) => (
|
||||
<Box key={label} sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', mb: 1 }}>{label}</Typography>
|
||||
<TextField
|
||||
type="date"
|
||||
value={selectedHistoryDate}
|
||||
onChange={(e) => setSelectedHistoryDate(e.target.value)}
|
||||
value={value}
|
||||
onChange={(e) => setter(e.target.value)}
|
||||
fullWidth
|
||||
sx={{
|
||||
mb: 3,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: '#f8fafc',
|
||||
'& fieldset': { borderColor: '#334155' },
|
||||
@@ -336,33 +304,37 @@ export const TopFilterBar: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 1, fontWeight: 'bold' }}>
|
||||
Quick Select (Audited Dates)
|
||||
Quick Select (Dates You Audited)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, maxHeight: 150, overflowY: 'auto', p: 0.5, border: '1px solid #1e293b', borderRadius: 1, bgcolor: '#0b1121' }}>
|
||||
{loadingRectHistory ? (
|
||||
{(historyDialogMode === 'rect' ? loadingRectHistory : loadingHistory) ? (
|
||||
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
</Box>
|
||||
) : rectHistoryDates.length === 0 ? (
|
||||
) : (historyDialogMode === 'rect' ? rectHistoryDates : historyDates).length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: '#64748b', p: 1 }}>No dates found</Typography>
|
||||
) : (
|
||||
rectHistoryDates.map((d) => {
|
||||
(historyDialogMode === 'rect' ? rectHistoryDates : historyDates).map((d) => {
|
||||
const dateStr = d.audited_on ? d.audited_on.split(' ')[0] : '';
|
||||
const selected = historyFrom === dateStr && historyTill === dateStr;
|
||||
return (
|
||||
<Chip
|
||||
key={d.audited_on}
|
||||
label={dateStr || d.audited_on}
|
||||
onClick={() => setSelectedHistoryDate(dateStr || d.audited_on)}
|
||||
onClick={() => { setHistoryFrom(dateStr); setHistoryTill(dateStr); }}
|
||||
sx={{
|
||||
bgcolor: selectedHistoryDate === (dateStr || d.audited_on) ? '#3b82f6' : '#1e293b',
|
||||
bgcolor: selected ? '#3b82f6' : '#1e293b',
|
||||
color: '#f8fafc',
|
||||
'&:hover': { bgcolor: '#334155' },
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
borderColor: selectedHistoryDate === (dateStr || d.audited_on) ? '#60a5fa' : 'transparent',
|
||||
borderColor: selected ? '#60a5fa' : 'transparent',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -380,7 +352,7 @@ export const TopFilterBar: React.FC = () => {
|
||||
<Button
|
||||
onClick={handleViewHistory}
|
||||
variant="contained"
|
||||
disabled={!selectedHistoryDate}
|
||||
disabled={!historyFrom || !historyTill}
|
||||
sx={{
|
||||
bgcolor: '#3b82f6',
|
||||
color: '#fff',
|
||||
|
||||
@@ -4,13 +4,14 @@ import MenuIcon from '@mui/icons-material/Menu';
|
||||
import AccountCircle from '@mui/icons-material/AccountCircle';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { accountService } from '../../api/accountService';
|
||||
|
||||
interface HeaderProps {
|
||||
toggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ toggleSidebar }) => {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { user, logout, hasRole } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
@@ -25,8 +26,20 @@ export const Header: React.FC<HeaderProps> = ({ toggleSidebar }) => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = async () => {
|
||||
handleClose();
|
||||
const refreshToken = user?.refresh_token;
|
||||
// Best-effort: tell the server the session ended (clears the refresh
|
||||
// token and sets user_sessions.logout_at) before clearing local state.
|
||||
// If this fails (network blip, already-expired token), still log out
|
||||
// locally rather than trapping the user in a broken session.
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await accountService.logout(refreshToken);
|
||||
} catch (err) {
|
||||
console.error('Failed to notify server of logout', err);
|
||||
}
|
||||
}
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
@@ -78,6 +91,21 @@ export const Header: React.FC<HeaderProps> = ({ toggleSidebar }) => {
|
||||
>
|
||||
Activity Feeds
|
||||
</Button>
|
||||
{hasRole('ADMIN') && (
|
||||
<Button
|
||||
onClick={() => navigate('/admin')}
|
||||
sx={{
|
||||
color: location.pathname.includes('admin') ? 'primary.main' : 'text.secondary',
|
||||
fontWeight: 'bold',
|
||||
borderBottom: location.pathname.includes('admin') ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
borderRadius: 0,
|
||||
px: 2,
|
||||
py: 1
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
|
||||
14
src/constants/media.ts
Normal file
14
src/constants/media.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// CDN prefixes for record media. These values are duplicated inline in
|
||||
// AuditSession.tsx and PreviewPanel.tsx (pre-existing) - new code should
|
||||
// import from here instead of adding more copies.
|
||||
export const MASTER_IMAGE_PREFIX_AUDITS = 'https://auditor-master-images.seekright.com/SeekRight/';
|
||||
export const MASTER_IMAGE_PREFIX_RECT = 'https://sr-img.seekright.com/';
|
||||
export const TEST_IMAGE_PREFIX = 'https://dashboard-images.seekright.com/SeekRight/';
|
||||
|
||||
export const masterImageUrl = (frameMaster: string | null | undefined, isRectification: boolean) =>
|
||||
frameMaster
|
||||
? `${isRectification ? MASTER_IMAGE_PREFIX_RECT : MASTER_IMAGE_PREFIX_AUDITS}${frameMaster}`
|
||||
: null;
|
||||
|
||||
export const testImageUrl = (frameTest: string | null | undefined) =>
|
||||
frameTest ? `${TEST_IMAGE_PREFIX}${frameTest}` : null;
|
||||
@@ -1,50 +1,26 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, List, ListItem, ListItemButton, ListItemText, CircularProgress, Box } from '@mui/material';
|
||||
import { accountService } from '../../api/accountService';
|
||||
|
||||
interface Organization {
|
||||
org_name: string;
|
||||
org_id: string;
|
||||
db_name: string;
|
||||
}
|
||||
import React from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, List, ListItem, ListItemButton, ListItemText } from '@mui/material';
|
||||
import type { Organization } from '../../api/accountService';
|
||||
|
||||
interface DbSelectionDialogProps {
|
||||
open: boolean;
|
||||
// The org list is decided by the login flow (scoped to orgs where the user
|
||||
// has work, or the full list for supervisors) - this dialog just renders it.
|
||||
organizations: Organization[];
|
||||
onClose: (selectedOrg?: Organization) => void;
|
||||
}
|
||||
|
||||
export const DbSelectionDialog: React.FC<DbSelectionDialogProps> = ({ open, onClose }) => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLoading(true);
|
||||
accountService.getOrganizations()
|
||||
.then((res: any) => {
|
||||
const sorted = res.sort((a: Organization, b: Organization) =>
|
||||
export const DbSelectionDialog: React.FC<DbSelectionDialogProps> = ({ open, organizations, onClose }) => {
|
||||
const sorted = [...organizations].sort((a, b) =>
|
||||
a.org_name.toLowerCase() > b.org_name.toLowerCase() ? 1 : -1
|
||||
);
|
||||
setOrganizations(sorted);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => onClose()} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Select the organization</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<List>
|
||||
{organizations.map((org, index) => (
|
||||
{sorted.map((org, index) => (
|
||||
<ListItem disablePadding key={`${org.org_id || org.org_name}-${index}`}>
|
||||
<ListItemButton onClick={() => onClose(org)}>
|
||||
<ListItemText primary={org.org_name} />
|
||||
@@ -52,7 +28,6 @@ export const DbSelectionDialog: React.FC<DbSelectionDialogProps> = ({ open, onCl
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Box, Button, TextField, Container, Alert, Paper } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import type { Organization } from '../../api/accountService';
|
||||
import { DbSelectionDialog } from './DbSelectionDialog';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -11,7 +13,8 @@ export const Login: React.FC = () => {
|
||||
const [isInvalid, setIsInvalid] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [tempUserData, setTempUserData] = useState<any>(null);
|
||||
const [dialogOrgs, setDialogOrgs] = useState<Organization[]>([]);
|
||||
const [tempLoginResult, setTempLoginResult] = useState<Awaited<ReturnType<typeof accountService.login>> | null>(null);
|
||||
|
||||
const { login, isAuthenticated } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
@@ -23,56 +26,72 @@ export const Login: React.FC = () => {
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const commitLogin = (
|
||||
result: NonNullable<typeof tempLoginResult>,
|
||||
org: { org_id: number | string; db_name: string },
|
||||
) => {
|
||||
login({
|
||||
access_token: result.access_token,
|
||||
refresh_token: result.refresh_token,
|
||||
user_id: result.user.user_id,
|
||||
username: result.user.username,
|
||||
email: result.user.email,
|
||||
role: result.user.role_name,
|
||||
org_id: String(org.org_id),
|
||||
db_name: org.db_name,
|
||||
});
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsInvalid(false);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const userData = await accountService.login(username, password);
|
||||
const result = await accountService.login(username, password);
|
||||
|
||||
if (!userData) {
|
||||
if (!result?.user) {
|
||||
setIsInvalid(true);
|
||||
useToastStore.getState().showToast('Invalid username or password.', 'error');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = userData.user_roles ? userData.user_roles.toLowerCase().split(',') : [];
|
||||
// Org entry is driven by where the user actually has work: supervisors
|
||||
// get the full list, everyone else only orgs with records assigned to
|
||||
// (or audited by) them. One org -> straight in; several -> pick;
|
||||
// none -> land in their profile org with an empty feed.
|
||||
let orgs: Organization[] = [];
|
||||
try {
|
||||
orgs = await accountService.getMyOrganizations(result.access_token);
|
||||
} catch {
|
||||
orgs = [];
|
||||
}
|
||||
|
||||
if (roles.includes('sr_auditor')) {
|
||||
// Open DB Selection Dialog
|
||||
setTempUserData(userData);
|
||||
setDialogOpen(true);
|
||||
} else if (roles.includes('auditor')) {
|
||||
// Direct login
|
||||
login(userData);
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
} else if (roles.includes('video_uploader')) {
|
||||
// Direct login for dashboard
|
||||
login(userData);
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
if (orgs.length === 1) {
|
||||
commitLogin(result, orgs[0]);
|
||||
} else if (orgs.length === 0) {
|
||||
useToastStore.getState().showToast('No work assigned to you yet.', 'info');
|
||||
commitLogin(result, { org_id: result.user.org_id, db_name: result.user.db_name });
|
||||
} else {
|
||||
alert("Your account is not configured to use this website. Please contact your admin for more info.");
|
||||
setTempLoginResult(result);
|
||||
setDialogOrgs(orgs);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsInvalid(true);
|
||||
useToastStore.getState().showToast('Invalid username or password.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDbSelection = (selectedOrg?: any) => {
|
||||
const handleDbSelection = (selectedOrg?: Organization) => {
|
||||
setDialogOpen(false);
|
||||
if (selectedOrg && tempUserData) {
|
||||
// Merge selected org details into user object
|
||||
const finalUserData = {
|
||||
...tempUserData,
|
||||
org_id: selectedOrg.org_id,
|
||||
db_name: selectedOrg.db_name,
|
||||
selected_org_name: selectedOrg.org_name
|
||||
};
|
||||
login(finalUserData);
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
if (selectedOrg && tempLoginResult) {
|
||||
commitLogin(tempLoginResult, selectedOrg);
|
||||
}
|
||||
setTempLoginResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -206,8 +225,7 @@ export const Login: React.FC = () => {
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
{/* Database Selection Dialog for SR_AUDITOR */}
|
||||
<DbSelectionDialog open={dialogOpen} onClose={handleDbSelection} />
|
||||
<DbSelectionDialog open={dialogOpen} organizations={dialogOrgs} onClose={handleDbSelection} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Checkbox, Skeleton } from '@mui/material';
|
||||
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Skeleton } from '@mui/material';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
|
||||
const SKELETON_COLS = 13;
|
||||
// Anomaly verdict. Audit_status is only a real verdict once a human has
|
||||
// audited the record (IsAudited = 1) - on pending rows it holds the
|
||||
// pipeline's pre-audit guess, so it must be ignored. Green True (1 → real
|
||||
// anomaly), red False (0 → safe), neutral when not yet audited.
|
||||
const VerdictPill: React.FC<{ isAudited: any; status: any }> = ({ isAudited, status }) => {
|
||||
const decided = Number(isAudited) === 1;
|
||||
const isTrue = decided && Number(status) === 1;
|
||||
const label = !decided ? '—' : isTrue ? 'True' : 'False';
|
||||
const color = !decided ? '#64748b' : isTrue ? '#22c55e' : '#ef4444';
|
||||
const bg = !decided ? 'rgba(100,116,139,0.12)' : isTrue ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)';
|
||||
return (
|
||||
<Box component="span" sx={{ display: 'inline-block', px: 1.25, py: 0.25, borderRadius: 1, fontSize: '0.72rem', fontWeight: 700, letterSpacing: 0.5, color, bgcolor: bg, border: `1px solid ${color}` }}>
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const SKELETON_COLS = 11;
|
||||
|
||||
export const AnomalyTable: React.FC = () => {
|
||||
const { tabs, selectedTabIndex, updateTab, filterPlaza, isRectificationEnabled, isFeedLoading } = useFeedStore();
|
||||
@@ -61,8 +78,6 @@ export const AnomalyTable: React.FC = () => {
|
||||
<TableCell sx={{ width: '10%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>ANOMALY<br/>True/False</TableCell>
|
||||
<TableCell sx={{ width: '6%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>FEATURES</TableCell>
|
||||
<TableCell sx={{ width: '6%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{(activeTab?.date === '' && isRectificationEnabled) ? 'ISSUE' : 'COMMENTS'}</TableCell>
|
||||
<TableCell sx={{ width: '5%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', textAlign: 'center', whiteSpace: 'nowrap' }}>CHECKED</TableCell>
|
||||
<TableCell sx={{ width: '8%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', textAlign: 'center', whiteSpace: 'nowrap' }}>BULK AUDIT</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -118,37 +133,11 @@ export const AnomalyTable: React.FC = () => {
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle' }}>{assetName}</TableCell>
|
||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Side || 'N/A'}</TableCell>
|
||||
<TableCell sx={{ width: '10%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle' }}>
|
||||
<Box sx={{ width: '100%', minWidth: 60 }}>
|
||||
<Slider
|
||||
size="small"
|
||||
value={anomaly.Audit_status || anomaly.anomaly_status_slider || 50}
|
||||
step={100}
|
||||
marks
|
||||
min={0}
|
||||
max={100}
|
||||
sx={{ color: '#ff4d4f' }}
|
||||
/>
|
||||
</Box>
|
||||
<TableCell sx={{ width: '10%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
|
||||
<VerdictPill isAudited={anomaly.IsAudited} status={anomaly.Audit_status} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Features || 'N/A'}</TableCell>
|
||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Audit_value || '—'}</TableCell>
|
||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Comments || '-'}</TableCell>
|
||||
<TableCell sx={{ width: '5%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
|
||||
<Checkbox size="small" checked={!!anomaly.IsAudited} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: '8%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={!!anomaly.isSelectedForBulkAudit}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation(); // prevent row click selection
|
||||
anomaly.isSelectedForBulkAudit = e.target.checked;
|
||||
// Trigger a re-render of the current active tab
|
||||
const updatedAnomalies = [...rawAnomalies];
|
||||
updateTab(selectedTabIndex, { anomalies: updatedAnomalies });
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useMemo, useCallback, memo } from 'react';
|
||||
import {
|
||||
Box, TextField, Checkbox, FormControlLabel, Button, Typography, Dialog,
|
||||
DialogContent, DialogActions, CircularProgress, Divider, InputAdornment, Chip
|
||||
DialogContent, DialogActions, CircularProgress, Divider, InputAdornment, Chip,
|
||||
Select, MenuItem
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
@@ -70,6 +71,7 @@ export const FeedFilters: React.FC = () => {
|
||||
filterFromDate, filterTillDate,
|
||||
filterMinChainage, filterMaxChainage,
|
||||
setFilterMinChainage, setFilterMaxChainage,
|
||||
sortBy, sortDir, setSortBy, setSortDir,
|
||||
} = useFeedStore();
|
||||
|
||||
// ── Use a Set (stored in state) for O(1) has() lookups ────────────────────
|
||||
@@ -77,6 +79,8 @@ export const FeedFilters: React.FC = () => {
|
||||
const [draftSet, setDraftSet] = useState<Set<number>>(() => new Set(selectedAssetIds));
|
||||
const [draftFromDate, setDraftFromDate] = useState('');
|
||||
const [draftTillDate, setDraftTillDate] = useState('');
|
||||
const [draftSortBy, setDraftSortBy] = useState<string>(() => sortBy);
|
||||
const [draftSortDir, setDraftSortDir] = useState<string>(() => sortDir);
|
||||
const [draftMinChainage, setDraftMinChainage] = useState('');
|
||||
const [draftMaxChainage, setDraftMaxChainage] = useState('');
|
||||
const [assetSearch, setAssetSearch] = useState('');
|
||||
@@ -146,6 +150,8 @@ export const FeedFilters: React.FC = () => {
|
||||
setDraftTillDate('');
|
||||
setDraftMinChainage('');
|
||||
setDraftMaxChainage('');
|
||||
setDraftSortBy('');
|
||||
setDraftSortDir('asc');
|
||||
setAssetSearch('');
|
||||
}, []);
|
||||
|
||||
@@ -162,6 +168,8 @@ export const FeedFilters: React.FC = () => {
|
||||
setFilterMinChainage(draftMinChainage); // persist chainage globally
|
||||
setFilterMaxChainage(draftMaxChainage); // persist chainage globally
|
||||
setSelectedAssetIds(draftAssetArr);
|
||||
setSortBy(draftSortBy as any);
|
||||
setSortDir(draftSortDir as any);
|
||||
|
||||
if (selectedTabIndex >= 0) {
|
||||
const activeTab = tabs[selectedTabIndex];
|
||||
@@ -172,10 +180,13 @@ export const FeedFilters: React.FC = () => {
|
||||
const assetIdsKey = draftAssetArr.join(',');
|
||||
|
||||
// Build the correct filter key that will be calculated in GlobalFeed
|
||||
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${draftFromDate}|${draftTillDate}|${draftMinChainage}|${draftMaxChainage}|${assetIdsKey}`;
|
||||
// Must mirror GlobalFeed's filterKey format exactly or the cache
|
||||
// check there misses and double-fetches.
|
||||
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${activeTab.auditedByMe ?? false}|${draftFromDate}|${draftTillDate}|${draftMinChainage}|${draftMaxChainage}|${assetIdsKey}|${draftSortBy}|${draftSortDir}`;
|
||||
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab.date,
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
fromDate: draftFromDate,
|
||||
tillDate: draftTillDate,
|
||||
pageNo: 0,
|
||||
@@ -189,6 +200,8 @@ export const FeedFilters: React.FC = () => {
|
||||
fromChainage: draftMinChainage,
|
||||
toChainage: draftMaxChainage,
|
||||
plaza: filterPlaza,
|
||||
sortBy: draftSortBy,
|
||||
sortDir: draftSortDir,
|
||||
});
|
||||
if (historyRes?.anomalies) {
|
||||
updateTab(selectedTabIndex, {
|
||||
@@ -246,6 +259,32 @@ export const FeedFilters: React.FC = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Sort - drives feed order, session page order and dashboard batch
|
||||
numbering (one source of truth end to end) */}
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', whiteSpace: 'nowrap' }}>Sort by</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={draftSortBy}
|
||||
onChange={(e) => setDraftSortBy(String(e.target.value))}
|
||||
displayEmpty
|
||||
sx={{ minWidth: 140, color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.07)', borderRadius: 1, '& fieldset': { borderColor: '#334155' }, '& .MuiSvgIcon-root': { color: '#94a3b8' } }}
|
||||
MenuProps={{ slotProps: { paper: { sx: { bgcolor: '#0f172a', border: '1px solid #1e293b' } } } }}
|
||||
>
|
||||
{[['', 'Default'], ['id', 'ID'], ['date', 'Date'], ['chainage', 'Chainage'], ['latlong', 'Lat/Long']].map(([v, l]) => (
|
||||
<MenuItem key={v} value={v} sx={{ color: '#f8fafc', bgcolor: '#0f172a', '&:hover': { bgcolor: '#1e293b' } }}>{l}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!draftSortBy}
|
||||
onClick={() => setDraftSortDir(d => (d === 'asc' ? 'desc' : 'asc'))}
|
||||
sx={{ color: '#94a3b8', border: '1px solid #334155', textTransform: 'none', borderRadius: 1, minWidth: 72, '&.Mui-disabled': { color: '#475569', borderColor: '#1e293b' } }}
|
||||
>
|
||||
{draftSortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Date & Chainage */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 3, alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const FeedTabs: React.FC = () => {
|
||||
}}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{tab.date === '' ? 'Unaudited' : tab.date}
|
||||
{tab.date === '' ? 'Unaudited' : tab.date.replace('_', ' – ')}
|
||||
{index !== 0 && ( // Don't allow closing the first tab usually
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); closeTab(index); }} sx={{ ml: 1, color: 'inherit', p: 0.5 }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
|
||||
@@ -16,7 +16,8 @@ export const GlobalFeed: React.FC = () => {
|
||||
filterFromDate, filterTillDate,
|
||||
filterMinChainage, filterMaxChainage,
|
||||
selectedAssetIds,
|
||||
setIsFeedLoading
|
||||
setIsFeedLoading,
|
||||
sortBy, sortDir
|
||||
} = useFeedStore();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,7 +61,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
const assetIdsKey = selectedAssetIds.join(',');
|
||||
|
||||
// Build a key for the current filter state on the active tab
|
||||
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${filterFromDate}|${filterTillDate}|${filterMinChainage}|${filterMaxChainage}|${assetIdsKey}`;
|
||||
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${activeTab.auditedByMe ?? false}|${filterFromDate}|${filterTillDate}|${filterMinChainage}|${filterMaxChainage}|${assetIdsKey}|${sortBy}|${sortDir}`;
|
||||
|
||||
// CACHE HIT: This exact tab and filter combo was already fetched
|
||||
if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) {
|
||||
@@ -75,6 +76,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab.date || '',
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
pageNo: 0,
|
||||
pageSize: 20,
|
||||
siteIds: siteIdParam,
|
||||
@@ -83,6 +85,8 @@ export const GlobalFeed: React.FC = () => {
|
||||
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
|
||||
isRectificationEnabled: isRectActive,
|
||||
sortByDateAsc: false,
|
||||
sortBy,
|
||||
sortDir,
|
||||
anomalyIds: searchAnomalyId,
|
||||
plaza: filterPlaza,
|
||||
assetsIds: assetIdsKey,
|
||||
@@ -116,7 +120,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
fetchFilteredHistory();
|
||||
}, [
|
||||
selectedSite?.site_id, auditStatus, searchAnomalyId, filterPlaza, isRectificationEnabled, selectedTabIndex, tabs.length,
|
||||
filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, selectedAssetIds
|
||||
filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, selectedAssetIds, sortBy, sortDir
|
||||
]);
|
||||
|
||||
const handlePageChange = async (_event: unknown, newPage: number) => {
|
||||
@@ -129,6 +133,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab?.date || '',
|
||||
auditedByMe: activeTab?.auditedByMe || false,
|
||||
pageNo: newPage * rowsPerPage,
|
||||
pageSize: rowsPerPage,
|
||||
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
||||
@@ -137,6 +142,8 @@ export const GlobalFeed: React.FC = () => {
|
||||
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
|
||||
isRectificationEnabled: isRectActive,
|
||||
sortByDateAsc: false,
|
||||
sortBy,
|
||||
sortDir,
|
||||
anomalyIds: searchAnomalyId,
|
||||
plaza: filterPlaza,
|
||||
assetsIds: assetIdsKey,
|
||||
@@ -169,6 +176,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab?.date || '',
|
||||
auditedByMe: activeTab?.auditedByMe || false,
|
||||
pageNo: newPage * newRowsPerPage,
|
||||
pageSize: newRowsPerPage,
|
||||
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
||||
|
||||
@@ -29,8 +29,15 @@ export const PreviewPanel: React.FC = () => {
|
||||
: "https://auditor-master-images.seekright.com/SeekRight/";
|
||||
const IMAGES_TEST_AUDIT_PREFIX = "https://dashboard-images.seekright.com/SeekRight/";
|
||||
|
||||
// Linear rectification has no master image; show the open defect's most
|
||||
// recent detection frame (recent_image from the anomaly table) as the
|
||||
// left-side reference instead.
|
||||
const showingRecentImage = !!(isRectActive && anomaly.Algorithm === 'linear' && anomaly.recent_image);
|
||||
|
||||
let masterImageSrc = '';
|
||||
if (anomaly.Frame_Master) {
|
||||
if (showingRecentImage) {
|
||||
masterImageSrc = IMAGES_TEST_AUDIT_PREFIX + anomaly.recent_image;
|
||||
} else if (anomaly.Frame_Master) {
|
||||
masterImageSrc = IMAGES_AWS_PREFIX + anomaly.Frame_Master;
|
||||
} else if (!anomaly.Frame_Master && !anomaly.Frame_Test) {
|
||||
masterImageSrc = '/assets/images/master_and_shift_image_not_available.png';
|
||||
@@ -52,12 +59,13 @@ export const PreviewPanel: React.FC = () => {
|
||||
alt="Master"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
if (!e.currentTarget.src.includes('master_image_not_available.png')) {
|
||||
e.currentTarget.src = '/assets/images/master_image_not_available.png';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ bgcolor: 'rgba(255,255,255,0.05)', color: 'text.primary', textAlign: 'center', py: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>MASTER IMAGE</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{showingRecentImage ? 'RECENT IMAGE' : 'MASTER IMAGE'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -67,8 +75,9 @@ export const PreviewPanel: React.FC = () => {
|
||||
alt="Anomaly"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
if (!e.currentTarget.src.includes('anomaly-placeholder.png')) {
|
||||
e.currentTarget.src = '/assets/images/anomaly-placeholder.png';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ bgcolor: 'rgba(255,255,255,0.05)', color: 'text.primary', textAlign: 'center', py: 0.5 }}>
|
||||
|
||||
40
src/pages/admin/AdminConsole.tsx
Normal file
40
src/pages/admin/AdminConsole.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Tabs, Tab } from '@mui/material';
|
||||
import { ClientsOverview } from '../clients/ClientsOverview';
|
||||
import { UserManagement } from './UserManagement';
|
||||
import { LoginHistory } from './LoginHistory';
|
||||
import { Allocation } from './Allocation';
|
||||
import { AuditHistory } from './AuditHistory';
|
||||
import { DevTickets } from './DevTickets';
|
||||
import { MissingMedia } from './MissingMedia';
|
||||
|
||||
export const AdminConsole: React.FC = () => {
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
return (
|
||||
// MainLayout's <main> is height:100vh + overflow:hidden (by design, so other
|
||||
// pages can own their own internal scroll regions - see AnomalyTable.tsx/
|
||||
// GlobalFeed.tsx for the same pattern). This tab's content (filters + large
|
||||
// tables) can exceed the viewport, so it needs its own bounded, scrollable
|
||||
// container rather than relying on the page ever growing taller than 100vh.
|
||||
<Box sx={{ p: 3, height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
|
||||
<Tabs value={tab} onChange={(_, value) => setTab(value)} sx={{ mb: 3 }}>
|
||||
<Tab label="Clients" />
|
||||
<Tab label="Users" />
|
||||
<Tab label="Login History" />
|
||||
<Tab label="Allocation" />
|
||||
<Tab label="Audit & Rectification History" />
|
||||
<Tab label="Dev Tickets" />
|
||||
<Tab label="Missing Media" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && <ClientsOverview />}
|
||||
{tab === 1 && <UserManagement />}
|
||||
{tab === 2 && <LoginHistory />}
|
||||
{tab === 3 && <Allocation />}
|
||||
{tab === 4 && <AuditHistory />}
|
||||
{tab === 5 && <DevTickets />}
|
||||
{tab === 6 && <MissingMedia />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
561
src/pages/admin/Allocation.tsx
Normal file
561
src/pages/admin/Allocation.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, Checkbox, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
|
||||
ToggleButton, Button, CircularProgress, TablePagination, Typography, Divider,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, DialogContentText, TextField,
|
||||
ListItemText, Alert,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { AllocationRecord, AdminUser } from '../../api/adminService';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
interface Organization {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
interface SiteOption {
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
}
|
||||
|
||||
// Mirrors the site-parsing logic in ClientsOverview.tsx/MainLayout.tsx —
|
||||
// /Master/site's response shape varies (responseData[0].records, nested
|
||||
// sub_sites, or a plain array).
|
||||
const parseSites = (sitesRes: any): SiteOption[] => {
|
||||
let parsedSites: any[] = [];
|
||||
if (sitesRes?.responseData?.[0]?.records) {
|
||||
parsedSites = sitesRes.responseData[0].records;
|
||||
if (parsedSites.length === 1 && Array.isArray(parsedSites[0])) {
|
||||
parsedSites = parsedSites[0];
|
||||
} else if (parsedSites.length > 0 && Array.isArray(parsedSites[0])) {
|
||||
parsedSites = parsedSites.flat();
|
||||
}
|
||||
} else if (Array.isArray(sitesRes)) {
|
||||
parsedSites = sitesRes;
|
||||
} else if (sitesRes?.data && Array.isArray(sitesRes.data)) {
|
||||
parsedSites = sitesRes.data;
|
||||
}
|
||||
|
||||
return parsedSites
|
||||
.map((site) => ({
|
||||
site_id: site.site_id || (site.sub_sites || []).map((s: any) => s.site_id).filter(Boolean).join(','),
|
||||
site_name: site.site_name || site.name || site.siteName || site.site_id,
|
||||
}))
|
||||
.filter((s) => s.site_id);
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const BULK_PRESETS = [1000, 2000, 5000, 10000];
|
||||
|
||||
// count split equally, remainder to the first users: 100/3 -> 34/33/33
|
||||
const computeSplit = (count: number, n: number): number[] => {
|
||||
const base = Math.floor(count / n);
|
||||
const rem = count % n;
|
||||
return Array.from({ length: n }, (_, i) => base + (i < rem ? 1 : 0));
|
||||
};
|
||||
|
||||
type ConfirmAction =
|
||||
| { kind: 'bulk'; count: number }
|
||||
| { kind: 'unassignAll' }
|
||||
| { kind: 'moveAll'; toUserId: number }
|
||||
| { kind: 'unassignSelected' };
|
||||
|
||||
export const Allocation: React.FC = () => {
|
||||
const { assets } = useFeedStore();
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [dbName, setDbName] = useState('');
|
||||
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [sort, setSort] = useState<'oldest' | 'newest'>('oldest');
|
||||
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const [siteId, setSiteId] = useState('');
|
||||
const [sites, setSites] = useState<SiteOption[]>([]);
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
// '' = all, 'null' = unassigned, otherwise a user_id string
|
||||
const [assignedFilter, setAssignedFilter] = useState('');
|
||||
const [customCount, setCustomCount] = useState('');
|
||||
const [moveTargetId, setMoveTargetId] = useState('');
|
||||
|
||||
const [records, setRecords] = useState<AllocationRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [orgsRes, usersRes] = await Promise.all([
|
||||
accountService.getOrganizations(),
|
||||
adminService.getUsers(),
|
||||
]);
|
||||
const orgs = (orgsRes as any) || [];
|
||||
setOrganizations(orgs);
|
||||
setUsers(usersRes.filter((u) => !u.disabled));
|
||||
if (orgs.length > 0) setDbName(orgs[0].db_name);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load organizations/users.', 'error');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Sites are org-scoped, and the org being managed here may differ from the
|
||||
// admin's own logged-in org, so this refetches whenever the selection changes
|
||||
// rather than reusing the global feedStore (which is scoped to the admin's own org).
|
||||
useEffect(() => {
|
||||
const org = organizations.find((o) => o.db_name === dbName);
|
||||
if (!org) return;
|
||||
(async () => {
|
||||
try {
|
||||
const sitesRes = await activityFeedsService.getOrganizationSites(org.org_id);
|
||||
setSites(parseSites(sitesRes));
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load sites for this organization.', 'error');
|
||||
}
|
||||
})();
|
||||
setSiteId('');
|
||||
}, [dbName, organizations]);
|
||||
|
||||
// dateTo is a bare date (e.g. "2026-07-09"); video_date is a full timestamp,
|
||||
// so comparing "<= 2026-07-09" would exclude same-day records after
|
||||
// midnight - extend it to end-of-day. Also normalize the order: both
|
||||
// fields are independent and unset by default, so it's easy to end up with
|
||||
// From after To while filling them in, which would silently match zero
|
||||
// rows instead of erroring.
|
||||
const [effectiveDateFrom, effectiveDateTo] = (() => {
|
||||
if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined];
|
||||
return dateFrom <= dateTo
|
||||
? [dateFrom, `${dateTo}T23:59:59`]
|
||||
: [dateTo, `${dateFrom}T23:59:59`];
|
||||
})();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!dbName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getAllocationRecords({
|
||||
dbName, type, sort, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
|
||||
dateFrom: effectiveDateFrom,
|
||||
dateTo: effectiveDateTo,
|
||||
assetId: assetId || undefined,
|
||||
siteId: siteId || undefined,
|
||||
assignedUserId: assignedFilter === '' ? undefined : assignedFilter === 'null' ? 'null' : Number(assignedFilter),
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load records.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dbName, type, sort, page, effectiveDateFrom, effectiveDateTo, assetId, siteId, assignedFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}, [dbName, type, sort, dateFrom, dateTo, assetId, siteId, assignedFilter]);
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.size === records.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(records.map((r) => r.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedUsers = users.filter((u) => selectedUserIds.includes(u.user_id));
|
||||
const selectedOrg = organizations.find((o) => o.db_name === dbName);
|
||||
const filterUser = users.find((u) => String(u.user_id) === assignedFilter);
|
||||
|
||||
const perUserToast = (perUser: { user_id: number; assigned: number }[]) =>
|
||||
perUser
|
||||
.map((p) => `${users.find((u) => u.user_id === p.user_id)?.username || `#${p.user_id}`}: ${p.assigned}`)
|
||||
.join(', ');
|
||||
|
||||
const handleAllocateSelected = async () => {
|
||||
if (selectedIds.size === 0 || selectedUserIds.length === 0) return;
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await adminService.assignAllocation({
|
||||
dbName, type, ids: Array.from(selectedIds), user_ids: selectedUserIds,
|
||||
});
|
||||
useToastStore.getState().showToast(
|
||||
`Allocated ${result.assigned} record(s) — ${perUserToast(result.perUser)}`, 'success');
|
||||
setSelectedIds(new Set());
|
||||
load();
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to allocate records.', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAllocate = async (count: number) => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
setConfirmAction(null);
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await adminService.assignAllocation({
|
||||
dbName, type, count, sort, user_ids: selectedUserIds,
|
||||
dateFrom: effectiveDateFrom,
|
||||
dateTo: effectiveDateTo,
|
||||
assetId: assetId || undefined,
|
||||
siteId: siteId || undefined,
|
||||
});
|
||||
useToastStore.getState().showToast(
|
||||
`Allocated ${result.assigned} record(s) — ${perUserToast(result.perUser)}`, 'success');
|
||||
setSelectedIds(new Set());
|
||||
setPage(0);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to bulk-allocate records.', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReassign = async (params: { fromUserId?: number; toUserId: number | null; ids?: number[] }) => {
|
||||
setConfirmAction(null);
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await adminService.reassignAllocation({ dbName, type, ...params });
|
||||
const verb = params.toUserId === null ? 'Unassigned' : 'Moved';
|
||||
useToastStore.getState().showToast(`${verb} ${result.moved} pending record(s).`, 'success');
|
||||
setSelectedIds(new Set());
|
||||
setMoveTargetId('');
|
||||
load();
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to update allocation.', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const customCountNum = parseInt(customCount, 10);
|
||||
const customCountValid = Number.isInteger(customCountNum) && customCountNum > 0 && customCountNum <= 10000;
|
||||
|
||||
const splitPreview = (count: number) => {
|
||||
if (selectedUsers.length === 0) return '';
|
||||
const split = computeSplit(count, selectedUsers.length);
|
||||
return selectedUsers.map((u, i) => `${u.username}: ${split[i]}`).join(' · ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Organization</InputLabel>
|
||||
<Select label="Organization" value={dbName} onChange={(e) => setDbName(e.target.value)}>
|
||||
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={type}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setType(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="audits">Audits</ToggleButton>
|
||||
<ToggleButton value="rectification">Rectification</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={sort}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setSort(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="oldest">Oldest first</ToggleButton>
|
||||
<ToggleButton value="newest">Newest first</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Video date from"
|
||||
type="date"
|
||||
size="small"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Video date to"
|
||||
type="date"
|
||||
size="small"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Asset</InputLabel>
|
||||
<Select label="Asset" value={assetId} onChange={(e) => setAssetId(e.target.value)}>
|
||||
<MenuItem value="">All assets</MenuItem>
|
||||
{assets.map((a: any) => <MenuItem key={a.id} value={a.id}>{a.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Site</InputLabel>
|
||||
<Select label="Site" value={siteId} onChange={(e) => setSiteId(e.target.value)}>
|
||||
<MenuItem value="">All sites</MenuItem>
|
||||
{sites.map((s) => <MenuItem key={s.site_id} value={s.site_id}>{s.site_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Assigned to</InputLabel>
|
||||
<Select label="Assigned to" value={assignedFilter} onChange={(e) => setAssignedFilter(e.target.value)}>
|
||||
<MenuItem value="">All records</MenuItem>
|
||||
<MenuItem value="null">Unassigned only</MenuItem>
|
||||
{users.map((u) => <MenuItem key={u.user_id} value={String(u.user_id)}>{u.username}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Absent-user actions: visible when viewing one user's queue */}
|
||||
{filterUser && (
|
||||
<Alert severity="info" sx={{ mb: 2, alignItems: 'center' }}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
disabled={assigning || total === 0}
|
||||
onClick={() => setConfirmAction({ kind: 'unassignAll' })}
|
||||
>
|
||||
Unassign all pending
|
||||
</Button>
|
||||
<FormControl size="small" sx={{ minWidth: 170 }}>
|
||||
<InputLabel>Move all pending to</InputLabel>
|
||||
<Select
|
||||
label="Move all pending to"
|
||||
value={moveTargetId}
|
||||
disabled={assigning || total === 0}
|
||||
onChange={(e) => {
|
||||
const target = Number(e.target.value);
|
||||
setMoveTargetId(e.target.value);
|
||||
setConfirmAction({ kind: 'moveAll', toUserId: target });
|
||||
}}
|
||||
>
|
||||
{users.filter((u) => String(u.user_id) !== assignedFilter).map((u) => (
|
||||
<MenuItem key={u.user_id} value={String(u.user_id)}>{u.username}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{total.toLocaleString()} pending record(s) assigned to <strong>{filterUser.username}</strong>
|
||||
{' '}(matching current filters)
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 280, mb: 2 }}>
|
||||
<InputLabel>Allocate to (select one or more)</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
label="Allocate to (select one or more)"
|
||||
value={selectedUserIds}
|
||||
onChange={(e) => setSelectedUserIds(typeof e.target.value === 'string' ? [] : (e.target.value as number[]))}
|
||||
renderValue={(ids) =>
|
||||
users.filter((u) => (ids as number[]).includes(u.user_id)).map((u) => u.username).join(', ')
|
||||
}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<MenuItem key={u.user_id} value={u.user_id}>
|
||||
<Checkbox checked={selectedUserIds.includes(u.user_id)} size="small" />
|
||||
<ListItemText primary={`${u.username} (${u.role_name})`} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2">{selectedIds.size} selected on this page</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={selectedIds.size === 0 || selectedUserIds.length === 0 || assigning}
|
||||
onClick={handleAllocateSelected}
|
||||
>
|
||||
{selectedUserIds.length > 1 ? 'Split selected equally' : 'Allocate selected'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={selectedIds.size === 0 || assigning}
|
||||
onClick={() => setConfirmAction({ kind: 'unassignSelected' })}
|
||||
>
|
||||
Unassign selected
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Bulk assign unassigned records (total, split equally · matches current filters):
|
||||
</Typography>
|
||||
{BULK_PRESETS.map((count) => (
|
||||
<Button
|
||||
key={count}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={selectedUserIds.length === 0 || assigning}
|
||||
onClick={() => setConfirmAction({ kind: 'bulk', count })}
|
||||
>
|
||||
{count.toLocaleString()}
|
||||
</Button>
|
||||
))}
|
||||
<TextField
|
||||
label="Custom count"
|
||||
size="small"
|
||||
type="number"
|
||||
value={customCount}
|
||||
onChange={(e) => setCustomCount(e.target.value)}
|
||||
sx={{ width: 130 }}
|
||||
slotProps={{ htmlInput: { min: 1, max: 10000 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={selectedUserIds.length === 0 || !customCountValid || assigning}
|
||||
onClick={() => setConfirmAction({ kind: 'bulk', count: customCountNum })}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={records.length > 0 && selectedIds.size === records.length}
|
||||
indeterminate={selectedIds.size > 0 && selectedIds.size < records.length}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>Anomaly ID</TableCell>
|
||||
<TableCell>Asset</TableCell>
|
||||
<TableCell>Plaza</TableCell>
|
||||
<TableCell>Chainage</TableCell>
|
||||
<TableCell>Video Date</TableCell>
|
||||
<TableCell>Assigned To</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id} selected={selectedIds.has(r.id)}>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox checked={selectedIds.has(r.id)} onChange={() => toggleRow(r.id)} />
|
||||
</TableCell>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell>{r.Asset || '—'}</TableCell>
|
||||
<TableCell>{r.Plaza || '—'}</TableCell>
|
||||
<TableCell>{r.Chainage || '—'}</TableCell>
|
||||
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
|
||||
<TableCell>{r.assigned_username || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={PAGE_SIZE}
|
||||
rowsPerPageOptions={[PAGE_SIZE]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Dialog open={confirmAction !== null} onClose={() => { setConfirmAction(null); setMoveTargetId(''); }}>
|
||||
<DialogTitle>
|
||||
{confirmAction?.kind === 'bulk' && 'Confirm bulk allocation'}
|
||||
{confirmAction?.kind === 'unassignAll' && 'Unassign all pending'}
|
||||
{confirmAction?.kind === 'moveAll' && 'Move all pending'}
|
||||
{confirmAction?.kind === 'unassignSelected' && 'Unassign selected'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{confirmAction?.kind === 'bulk' && (
|
||||
<DialogContentText component="div">
|
||||
Split the first {confirmAction.count.toLocaleString()} {sort} <strong>unassigned</strong> {type} record(s) in{' '}
|
||||
{selectedOrg?.org_name || dbName} (matching the current date/asset/site filters) equally:
|
||||
<Box sx={{ mt: 1.5, fontFamily: 'monospace', fontSize: '0.9rem' }}>
|
||||
{splitPreview(confirmAction.count)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
If fewer records are available, users earlier in the list are filled first.
|
||||
</Box>
|
||||
</DialogContentText>
|
||||
)}
|
||||
{confirmAction?.kind === 'unassignAll' && (
|
||||
<DialogContentText>
|
||||
Remove the assignment from all {total.toLocaleString()} pending record(s) currently allocated to{' '}
|
||||
<strong>{filterUser?.username}</strong> in {selectedOrg?.org_name || dbName}? Records they already
|
||||
audited keep their name for history/efficiency stats.
|
||||
</DialogContentText>
|
||||
)}
|
||||
{confirmAction?.kind === 'moveAll' && (
|
||||
<DialogContentText>
|
||||
Move all {total.toLocaleString()} pending record(s) from <strong>{filterUser?.username}</strong> to{' '}
|
||||
<strong>{users.find((u) => u.user_id === confirmAction.toUserId)?.username}</strong>? Records already
|
||||
audited by {filterUser?.username} keep their name for history/efficiency stats.
|
||||
</DialogContentText>
|
||||
)}
|
||||
{confirmAction?.kind === 'unassignSelected' && (
|
||||
<DialogContentText>
|
||||
Remove the assignment from the {selectedIds.size} selected pending record(s)?
|
||||
</DialogContentText>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setConfirmAction(null); setMoveTargetId(''); }}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.kind === 'bulk') handleBulkAllocate(confirmAction.count);
|
||||
else if (confirmAction.kind === 'unassignAll') handleReassign({ fromUserId: Number(assignedFilter), toUserId: null });
|
||||
else if (confirmAction.kind === 'moveAll') handleReassign({ fromUserId: Number(assignedFilter), toUserId: confirmAction.toUserId });
|
||||
else if (confirmAction.kind === 'unassignSelected') handleReassign({ toUserId: null, ids: Array.from(selectedIds) });
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
365
src/pages/admin/AuditHistory.tsx
Normal file
365
src/pages/admin/AuditHistory.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
|
||||
ToggleButton, CircularProgress, Typography, TextField, Chip, TablePagination,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { AuditHistoryUserCount, AuditHistoryRecord } from '../../api/adminService';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
interface Organization {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
type Preset = 'today' | 'overall' | 'custom_day' | 'custom_range';
|
||||
|
||||
const startOfDay = (dateStr: string) => `${dateStr}T00:00:00`;
|
||||
const endOfDay = (dateStr: string) => `${dateStr}T23:59:59`;
|
||||
const todayStr = () => new Date().toISOString().split('T')[0];
|
||||
const RECORDS_PAGE_SIZE = 25;
|
||||
|
||||
const efficiencyColor = (value: number): 'success' | 'warning' | 'error' => {
|
||||
if (value >= 75) return 'success';
|
||||
if (value >= 40) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
// "Assigned" is video_date-based (work whose video arrived in this window)
|
||||
// and "Completed" is Audited_on-based (work actually finished in this
|
||||
// window) - two independent measures, not two views of the same row set. A
|
||||
// user can complete backlog items today with nothing newly assigned today.
|
||||
|
||||
const SummaryCard: React.FC<{ label: string; value: string; color?: string }> = ({ label, value, color }) => (
|
||||
<Paper sx={{ p: 2, minWidth: 160 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', color }}>{value}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export const AuditHistory: React.FC = () => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [dbName, setDbName] = useState('');
|
||||
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [preset, setPreset] = useState<Preset>('today');
|
||||
const [customDay, setCustomDay] = useState(todayStr());
|
||||
const [rangeFrom, setRangeFrom] = useState(todayStr());
|
||||
const [rangeTo, setRangeTo] = useState(todayStr());
|
||||
|
||||
const [total, setTotal] = useState(0);
|
||||
const [audited, setAudited] = useState(0);
|
||||
const [notAudited, setNotAudited] = useState(0);
|
||||
const [byUser, setByUser] = useState<AuditHistoryUserCount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Drill-down: which user's individual records are currently shown below.
|
||||
const [drillUser, setDrillUser] = useState<AuditHistoryUserCount | null>(null);
|
||||
const [drillRecords, setDrillRecords] = useState<AuditHistoryRecord[]>([]);
|
||||
const [drillTotal, setDrillTotal] = useState(0);
|
||||
const [drillPage, setDrillPage] = useState(0);
|
||||
const [drillLoading, setDrillLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const orgsRes = await accountService.getOrganizations();
|
||||
const orgs = (orgsRes as any) || [];
|
||||
setOrganizations(orgs);
|
||||
if (orgs.length > 0) setDbName(orgs[0].db_name);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load organizations.', 'error');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const { from, to } = (() => {
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { from: startOfDay(todayStr()), to: endOfDay(todayStr()) };
|
||||
case 'custom_day':
|
||||
return { from: startOfDay(customDay), to: endOfDay(customDay) };
|
||||
case 'custom_range':
|
||||
// Both fields default to today, so it's easy to end up with an
|
||||
// inverted range while adjusting them one at a time (e.g. picking a
|
||||
// past "To" before touching "From") - that would silently match zero
|
||||
// rows instead of erroring, so normalize the order here.
|
||||
return rangeFrom <= rangeTo
|
||||
? { from: startOfDay(rangeFrom), to: endOfDay(rangeTo) }
|
||||
: { from: startOfDay(rangeTo), to: endOfDay(rangeFrom) };
|
||||
case 'overall':
|
||||
default:
|
||||
return { from: undefined, to: undefined };
|
||||
}
|
||||
})();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!dbName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getAuditHistorySummary({ dbName, type, from, to });
|
||||
setTotal(result.total);
|
||||
setAudited(result.audited);
|
||||
setNotAudited(result.notAudited);
|
||||
setByUser(result.byUser);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load audit history.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dbName, type, from, to]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Changing org/type/date invalidates whatever drill-down was open.
|
||||
setDrillUser(null);
|
||||
}, [load]);
|
||||
|
||||
const loadDrillRecords = useCallback(async (user: AuditHistoryUserCount, pageIndex: number) => {
|
||||
setDrillLoading(true);
|
||||
try {
|
||||
const result = await adminService.getAuditHistoryUserRecords({
|
||||
dbName, type, userId: user.user_id, from, to,
|
||||
limit: RECORDS_PAGE_SIZE, offset: pageIndex * RECORDS_PAGE_SIZE,
|
||||
});
|
||||
setDrillRecords(result.records);
|
||||
setDrillTotal(result.total);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load user records.', 'error');
|
||||
} finally {
|
||||
setDrillLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dbName, type, from, to]);
|
||||
|
||||
const handleRowClick = (user: AuditHistoryUserCount) => {
|
||||
if (drillUser?.user_id === user.user_id) {
|
||||
setDrillUser(null);
|
||||
return;
|
||||
}
|
||||
setDrillUser(user);
|
||||
setDrillPage(0);
|
||||
loadDrillRecords(user, 0);
|
||||
};
|
||||
|
||||
const handleDrillPageChange = (newPage: number) => {
|
||||
setDrillPage(newPage);
|
||||
if (drillUser) loadDrillRecords(drillUser, newPage);
|
||||
};
|
||||
|
||||
const completionRate = total > 0 ? Math.round((audited / total) * 1000) / 10 : 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Organization</InputLabel>
|
||||
<Select label="Organization" value={dbName} onChange={(e) => setDbName(e.target.value)}>
|
||||
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={type}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setType(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="audits">Audits</ToggleButton>
|
||||
<ToggleButton value="rectification">Rectification</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={preset}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setPreset(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="today">Today</ToggleButton>
|
||||
<ToggleButton value="overall">Overall</ToggleButton>
|
||||
<ToggleButton value="custom_day">Custom day</ToggleButton>
|
||||
<ToggleButton value="custom_range">Custom range</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center' }}>
|
||||
{preset === 'custom_day' && (
|
||||
<TextField
|
||||
label="Day"
|
||||
type="date"
|
||||
size="small"
|
||||
value={customDay}
|
||||
onChange={(e) => setCustomDay(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
)}
|
||||
{preset === 'custom_range' && (
|
||||
<>
|
||||
<TextField
|
||||
label="From"
|
||||
type="date"
|
||||
size="small"
|
||||
value={rangeFrom}
|
||||
onChange={(e) => setRangeFrom(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="To"
|
||||
type="date"
|
||||
size="small"
|
||||
value={rangeTo}
|
||||
onChange={(e) => setRangeTo(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
|
||||
<SummaryCard label="Total in range" value={total.toLocaleString()} />
|
||||
<SummaryCard label="Audited" value={audited.toLocaleString()} color="#22c55e" />
|
||||
<SummaryCard label="Not audited" value={notAudited.toLocaleString()} color="#f59e0b" />
|
||||
<SummaryCard label="Completion rate" value={`${completionRate}%`} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
|
||||
Per-user activity — click a row for their individual records
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ mb: 1, display: 'block', color: 'text.secondary' }}>
|
||||
Assigned = video arrived in this window · Completed = audited in this window (may include older backlog)
|
||||
</Typography>
|
||||
<TableContainer component={Paper} sx={{ mb: drillUser ? 3 : 0 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>User</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Records assigned to the user whose video date falls in the selected range">
|
||||
<span>Assigned</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Records the user completed within the selected range - includes backlog whose video arrived earlier, so this can exceed Assigned">
|
||||
<span>Completed</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Share of the user's own Assigned queue that is done - never exceeds 100%">
|
||||
<span>Queue Done %</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{byUser.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No activity in this range.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{byUser.map((u) => (
|
||||
<TableRow
|
||||
key={u.user_id}
|
||||
hover
|
||||
selected={drillUser?.user_id === u.user_id}
|
||||
onClick={() => handleRowClick(u)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell align="right">{u.assigned.toLocaleString()}</TableCell>
|
||||
<TableCell align="right">{u.completed.toLocaleString()}</TableCell>
|
||||
<TableCell align="right">
|
||||
{u.efficiency === null ? (
|
||||
<Chip size="small" label="N/A" variant="outlined" />
|
||||
) : (
|
||||
<Chip size="small" label={`${u.efficiency}%`} color={efficiencyColor(u.efficiency)} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{drillUser && (
|
||||
<>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
Records assigned to {drillUser.username}
|
||||
</Typography>
|
||||
{drillLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Anomaly ID</TableCell>
|
||||
<TableCell>Asset</TableCell>
|
||||
<TableCell>Plaza</TableCell>
|
||||
<TableCell>Chainage</TableCell>
|
||||
<TableCell>Video Date</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Audited On</TableCell>
|
||||
<TableCell>Audited By</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{drillRecords.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No records found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{drillRecords.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell>{r.Asset || '—'}</TableCell>
|
||||
<TableCell>{r.Plaza || '—'}</TableCell>
|
||||
<TableCell>{r.Chainage || '—'}</TableCell>
|
||||
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
|
||||
<TableCell>
|
||||
{r.IsAudited ? (
|
||||
<Chip size="small" label="Audited" color="success" />
|
||||
) : (
|
||||
<Chip size="small" label="Pending" color="default" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{r.Audited_on ? new Date(r.Audited_on).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell>
|
||||
{r.audited_username
|
||||
? r.audited_username === drillUser.username
|
||||
? r.audited_username
|
||||
: `${r.audited_username} (reassigned)`
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={drillTotal}
|
||||
page={drillPage}
|
||||
onPageChange={(_, newPage) => handleDrillPageChange(newPage)}
|
||||
rowsPerPage={RECORDS_PAGE_SIZE}
|
||||
rowsPerPageOptions={[RECORDS_PAGE_SIZE]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
305
src/pages/admin/DevTickets.tsx
Normal file
305
src/pages/admin/DevTickets.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
||||
Chip, TablePagination, Link, Tooltip, Button,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { DevTicket } from '../../api/adminService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
import { RecordDetailsDialog } from './RecordDetailsDialog';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const gitStatusColor = (status: DevTicket['git_status']): 'success' | 'warning' | 'error' => {
|
||||
if (status === 'success') return 'success';
|
||||
if (status === 'failed') return 'error';
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
// One unified lifecycle: a ticket is Resolved (from the UI or by closing
|
||||
// the Gitea issue), or its record is still Under Review / already handled.
|
||||
const statusChip = (t: DevTicket) => {
|
||||
if (t.resolved_at) {
|
||||
return (
|
||||
<Tooltip title={`Resolved by ${t.resolved_by || '—'} · ${new Date(t.resolved_at).toLocaleString()}`}>
|
||||
<Chip size="small" label="Resolved" color="success" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
switch (t.record_status) {
|
||||
case 'under_review': return <Chip size="small" label="Under Review" color="warning" />;
|
||||
case 'resolved': return <Chip size="small" label="Back in feed" color="success" variant="outlined" />;
|
||||
case 'audited': return <Chip size="small" label="Audited" color="info" />;
|
||||
case 'deleted': return <Chip size="small" label="Deleted" color="default" />;
|
||||
default:
|
||||
return (
|
||||
<Tooltip title="Record not found or its org DB is unreachable">
|
||||
<Chip size="small" label="Unknown" variant="outlined" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}…` : text);
|
||||
|
||||
export const DevTickets: React.FC = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [gitStatus, setGitStatus] = useState<'' | 'pending' | 'success' | 'failed'>('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [records, setRecords] = useState<DevTicket[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// dateTo is a bare date; created_at is a full timestamp, and both fields
|
||||
// are independently editable, so normalize the order and extend "to" to
|
||||
// end-of-day - see Allocation.tsx/AuditHistory.tsx for the same fix after
|
||||
// hitting this exact bug there (an inverted range silently matches zero
|
||||
// rows instead of erroring).
|
||||
const [effectiveDateFrom, effectiveDateTo] = (() => {
|
||||
if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined];
|
||||
return dateFrom <= dateTo
|
||||
? [dateFrom, `${dateTo}T23:59:59`]
|
||||
: [dateTo, `${dateFrom}T23:59:59`];
|
||||
})();
|
||||
|
||||
// Live Gitea issue state (open/closed) - git_status only ever records
|
||||
// whether we succeeded in *filing* the issue, never what happened to it
|
||||
// afterward, so this is fetched separately from the main list.
|
||||
const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({});
|
||||
const [issueStatesLoading, setIssueStatesLoading] = useState(false);
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||
const [detailsTicket, setDetailsTicket] = useState<DevTicket | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getDevTickets({
|
||||
search: search || undefined,
|
||||
gitStatus: gitStatus || undefined,
|
||||
dateFrom: effectiveDateFrom,
|
||||
dateTo: effectiveDateTo,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load dev tickets.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(load, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
// Keyed by ticket id, not issue number - the backend resolves each
|
||||
// ticket's own stored git_issue_url, so this stays correct even for
|
||||
// tickets filed before a repo migration.
|
||||
const ticketIds = records
|
||||
.filter((t) => t.git_status === 'success' && t.git_issue_number != null)
|
||||
.map((t) => t.id);
|
||||
|
||||
if (ticketIds.length === 0) {
|
||||
setIssueStates({});
|
||||
return;
|
||||
}
|
||||
|
||||
setIssueStatesLoading(true);
|
||||
adminService.getIssueStates(ticketIds)
|
||||
.then(setIssueStates)
|
||||
.catch(() => useToastStore.getState().showToast('Failed to check live Gitea issue status.', 'error'))
|
||||
.finally(() => setIssueStatesLoading(false));
|
||||
}, [records]);
|
||||
|
||||
const handleResolve = async (t: DevTicket) => {
|
||||
setResolvingId(t.id);
|
||||
try {
|
||||
const res = await adminService.resolveTicket(t.id);
|
||||
useToastStore.getState().showToast(
|
||||
res.recordReleased
|
||||
? `Resolved — record #${t.anomaly_id} returned to the feed${res.issueClosed ? ', git issue closed' : ''}.`
|
||||
: `Resolved — record was already handled${res.issueClosed ? '; git issue closed' : ''}.`,
|
||||
'success'
|
||||
);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to resolve ticket.', 'error');
|
||||
} finally {
|
||||
setResolvingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Search (dev, reporter, message, anomaly ID)"
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Git status</InputLabel>
|
||||
<Select label="Git status" value={gitStatus} onChange={(e) => setGitStatus(e.target.value as any)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="pending">Pending</MenuItem>
|
||||
<MenuItem value="success">Success</MenuItem>
|
||||
<MenuItem value="failed">Failed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Created from"
|
||||
type="date"
|
||||
size="small"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Created to"
|
||||
type="date"
|
||||
size="small"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Anomaly ID</TableCell>
|
||||
<TableCell>Org</TableCell>
|
||||
<TableCell>Asset / Plaza</TableCell>
|
||||
<TableCell>Dev</TableCell>
|
||||
<TableCell>Reporter</TableCell>
|
||||
<TableCell>Message</TableCell>
|
||||
<TableCell>RocketChat</TableCell>
|
||||
<TableCell>Git</TableCell>
|
||||
<TableCell>Issue status</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{records.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No dev tickets found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{records.map((t) => {
|
||||
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
|
||||
return (
|
||||
<TableRow
|
||||
key={t.id}
|
||||
hover
|
||||
onClick={() => setDetailsTicket(t)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCell>{t.anomaly_id}</TableCell>
|
||||
<TableCell>{t.org_db_name || '—'}</TableCell>
|
||||
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
|
||||
<TableCell>@{t.dev_handle}</TableCell>
|
||||
<TableCell>{t.reporter || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={t.message_text}>
|
||||
<span>{truncate(t.message_text)}</span>
|
||||
</Tooltip>
|
||||
{t.duplicate_of_id && (
|
||||
<Chip size="small" label={`dup of #${t.duplicate_of_id}`} sx={{ ml: 1 }} variant="outlined" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.rocketchat_sent ? (
|
||||
<Chip size="small" label="Sent" color="success" />
|
||||
) : (
|
||||
<Tooltip title={t.rocketchat_error || 'Not sent'}>
|
||||
<Chip size="small" label="Failed" color="error" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{t.git_issue_url ? (
|
||||
<Link href={t.git_issue_url} target="_blank" rel="noopener noreferrer">
|
||||
<Chip size="small" label={`#${t.git_issue_number}`} color={gitStatusColor(t.git_status)} clickable />
|
||||
</Link>
|
||||
) : (
|
||||
<Tooltip title={t.git_error || ''}>
|
||||
<Chip size="small" label={t.git_status} color={gitStatusColor(t.git_status)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{t.retry_count > 0 && (
|
||||
<Chip size="small" label={`retries: ${t.retry_count}`} sx={{ ml: 1 }} variant="outlined" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.git_status !== 'success' ? (
|
||||
'—'
|
||||
) : issueStatesLoading && liveState === undefined ? (
|
||||
<CircularProgress size={14} />
|
||||
) : liveState === 'closed' ? (
|
||||
<Chip size="small" label="Closed" color="default" />
|
||||
) : liveState === 'open' ? (
|
||||
<Chip size="small" label="Open" color="info" />
|
||||
) : (
|
||||
<Tooltip title="Could not reach Gitea to check">
|
||||
<Chip size="small" label="Unknown" variant="outlined" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{statusChip(t)}</TableCell>
|
||||
<TableCell>{new Date(t.created_at).toLocaleString()}</TableCell>
|
||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!!t.resolved_at || t.record_status !== 'under_review' || resolvingId === t.id}
|
||||
onClick={() => handleResolve(t)}
|
||||
>
|
||||
{resolvingId === t.id ? <CircularProgress size={16} /> : 'Resolve'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={PAGE_SIZE}
|
||||
rowsPerPageOptions={[PAGE_SIZE]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<RecordDetailsDialog
|
||||
ticket={detailsTicket}
|
||||
liveIssueState={detailsTicket ? issueStates[detailsTicket.id] : undefined}
|
||||
onClose={() => setDetailsTicket(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
77
src/pages/admin/LoginHistory.tsx
Normal file
77
src/pages/admin/LoginHistory.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, TextField, CircularProgress, Chip,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { UserSession } from '../../api/adminService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
const formatDate = (value: string | null) => (value ? new Date(value).toLocaleString() : '—');
|
||||
|
||||
export const LoginHistory: React.FC = () => {
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async (usernameFilter: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getSessions({ username: usernameFilter || undefined });
|
||||
setSessions(result);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load login history.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load('');
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => load(username), 300);
|
||||
return () => clearTimeout(timeout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [username]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
label="Filter by username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
size="small"
|
||||
sx={{ mb: 2, width: 300 }}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Login At</TableCell>
|
||||
<TableCell>Logout At</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sessions.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{s.username}</TableCell>
|
||||
<TableCell>{formatDate(s.login_at)}</TableCell>
|
||||
<TableCell>
|
||||
{s.logout_at ? formatDate(s.logout_at) : <Chip size="small" label="Active" color="success" />}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
231
src/pages/admin/MissingMedia.tsx
Normal file
231
src/pages/admin/MissingMedia.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, Checkbox, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
|
||||
ToggleButton, Button, CircularProgress, TablePagination, Typography, Chip, Tooltip,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { MissingMediaRecord, MissingMediaSummary, MissingMediaFilter } from '../../api/adminService';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
interface Organization {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const missingColor = (missing: string): 'default' | 'warning' | 'error' => {
|
||||
if (missing === 'not checked yet') return 'default';
|
||||
if (missing === 'video') return 'warning';
|
||||
return 'error'; // image or image + video - definitely unauditable
|
||||
};
|
||||
|
||||
export const MissingMedia: React.FC = () => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [dbName, setDbName] = useState('');
|
||||
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [missing, setMissing] = useState<'all' | MissingMediaFilter>('all');
|
||||
const [records, setRecords] = useState<MissingMediaRecord[]>([]);
|
||||
const [summary, setSummary] = useState<MissingMediaSummary | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const orgs = ((await accountService.getOrganizations()) as any) || [];
|
||||
setOrganizations(orgs);
|
||||
if (orgs.length > 0) setDbName(orgs[0].db_name);
|
||||
} catch {
|
||||
useToastStore.getState().showToast('Failed to load organizations.', 'error');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!dbName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getMissingMedia({
|
||||
dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
|
||||
...(missing !== 'all' ? { missing } : {}),
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
setSummary(result.summary);
|
||||
// A recheck under an active filter can shrink the filtered total below
|
||||
// the current page - snap back instead of stranding on an empty page.
|
||||
if (page > 0 && result.total <= page * PAGE_SIZE) setPage(0);
|
||||
} catch {
|
||||
useToastStore.getState().showToast('Failed to load missing-media records.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dbName, type, page, missing]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}, [dbName, type, missing]);
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(selectedIds.size === records.length ? new Set() : new Set(records.map((r) => r.id)));
|
||||
};
|
||||
|
||||
const handleRecheck = async (ids: number[]) => {
|
||||
setRechecking(true);
|
||||
try {
|
||||
const result = await adminService.recheckMedia({ dbName, type, ids });
|
||||
useToastStore.getState().showToast(
|
||||
`Rechecked ${result.checked}: ${result.ready} now ready, ${result.missing} still missing${result.skipped ? `, ${result.skipped} unreachable` : ''}.`,
|
||||
result.ready > 0 ? 'success' : 'info'
|
||||
);
|
||||
setSelectedIds(new Set());
|
||||
load();
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Recheck failed.', 'error');
|
||||
} finally {
|
||||
setRechecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const summaryChip = (label: string, value: number | undefined, color: 'default' | 'warning' | 'error' | 'info') => (
|
||||
<Chip label={`${label}: ${value ?? '…'}`} color={color === 'default' ? undefined : color} variant="outlined" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Organization</InputLabel>
|
||||
<Select label="Organization" value={dbName} onChange={(e) => setDbName(e.target.value)}>
|
||||
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<ToggleButtonGroup value={type} exclusive onChange={(_, v) => v && setType(v)} size="small">
|
||||
<ToggleButton value="audits">Audits</ToggleButton>
|
||||
<ToggleButton value="rectification">Rectification</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<ToggleButtonGroup value={missing} exclusive onChange={(_, v) => v && setMissing(v)} size="small">
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
<ToggleButton value="image">Image missing</ToggleButton>
|
||||
<ToggleButton value="video">Video missing</ToggleButton>
|
||||
<ToggleButton value="both">Both missing</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={selectedIds.size === 0 || selectedIds.size > 50 || rechecking}
|
||||
onClick={() => handleRecheck(Array.from(selectedIds))}
|
||||
>
|
||||
{rechecking ? <CircularProgress size={20} sx={{ color: 'white' }} /> : `Recheck selected (${selectedIds.size})`}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
max 50 at a time — the 15-min cron keeps checking the rest automatically
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap' }}>
|
||||
{summaryChip('Hidden from auditing', summary?.total, 'error')}
|
||||
{summaryChip('Not checked yet', summary?.neverChecked, 'default')}
|
||||
{summaryChip('Image missing', summary?.missingImage, 'error')}
|
||||
{summaryChip('Video missing', summary?.missingVideo, 'warning')}
|
||||
{summaryChip('Both missing', summary?.missingBoth, 'error')}
|
||||
{summaryChip('Gave up (24h of retries)', summary?.gaveUp, 'info')}
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={records.length > 0 && selectedIds.size === records.length}
|
||||
indeterminate={selectedIds.size > 0 && selectedIds.size < records.length}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>Record ID</TableCell>
|
||||
<TableCell>Asset</TableCell>
|
||||
<TableCell>Plaza</TableCell>
|
||||
<TableCell>Site</TableCell>
|
||||
<TableCell>Video Date</TableCell>
|
||||
<TableCell>Missing</TableCell>
|
||||
<TableCell align="right">Checks</TableCell>
|
||||
<TableCell>Last Checked</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{records.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
|
||||
{missing !== 'all'
|
||||
? 'No records match this filter.'
|
||||
: 'No pending records with missing media — everything is auditable. 🎉'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id} selected={selectedIds.has(r.id)}>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox checked={selectedIds.has(r.id)} onChange={() => toggleRow(r.id)} />
|
||||
</TableCell>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell>{r.Asset || '—'}</TableCell>
|
||||
<TableCell>{r.Plaza || '—'}</TableCell>
|
||||
<TableCell>{r.site_id}</TableCell>
|
||||
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={r.missing} color={missingColor(r.missing)} />
|
||||
{!r.has_video_declared && (
|
||||
<Tooltip title="Record declares no video, so only the image gates it">
|
||||
<Chip size="small" label="no video declared" variant="outlined" sx={{ ml: 0.5 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{r.media_check_count >= 96 ? (
|
||||
<Tooltip title="Gave up after ~24h of retries — use Recheck to resume">
|
||||
<Chip size="small" label={r.media_check_count} color="warning" />
|
||||
</Tooltip>
|
||||
) : r.media_check_count}
|
||||
</TableCell>
|
||||
<TableCell>{r.media_checked_at ? new Date(r.media_checked_at).toLocaleString() : 'never'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={PAGE_SIZE}
|
||||
rowsPerPageOptions={[PAGE_SIZE]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
212
src/pages/admin/RecordDetailsDialog.tsx
Normal file
212
src/pages/admin/RecordDetailsDialog.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Box,
|
||||
Typography, Chip, CircularProgress, Link, Divider, IconButton,
|
||||
} from '@mui/material';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { DevTicket, TicketRecordDetails } from '../../api/adminService';
|
||||
import { masterImageUrl, testImageUrl, TEST_IMAGE_PREFIX } from '../../constants/media';
|
||||
|
||||
interface RecordDetailsDialogProps {
|
||||
ticket: DevTicket | null; // null = closed
|
||||
liveIssueState?: 'open' | 'closed' | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<Box sx={{ minWidth: 140 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{label}</Typography>
|
||||
<Typography variant="body2">{children ?? '—'}</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const MediaImage: React.FC<{ label: string; src: string | null }> = ({ label, src }) => {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => { setFailed(false); }, [src]);
|
||||
return (
|
||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{label}</Typography>
|
||||
{src && !failed ? (
|
||||
<Box component="img" src={src} onError={() => setFailed(true)}
|
||||
sx={{ width: '100%', maxHeight: 260, objectFit: 'contain', borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
|
||||
) : (
|
||||
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{src ? 'Image unavailable' : 'No image'}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface MediaItem {
|
||||
kind: 'image' | 'video';
|
||||
src: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Right-hand pane: the anomaly's test-side media (test image, extra images,
|
||||
// video) as a carousel. The <video> unmounts on navigation, so playback
|
||||
// stops automatically when moving to another item.
|
||||
const MediaCarousel: React.FC<{ items: MediaItem[] }> = ({ items }) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => { setIndex(0); }, [items.length]);
|
||||
useEffect(() => { setFailed(false); }, [index]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>Anomaly media</Typography>
|
||||
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>No media</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const item = items[Math.min(index, items.length - 1)];
|
||||
return (
|
||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{item.label}</Typography>
|
||||
{item.kind === 'video' ? (
|
||||
<Box component="video" key={item.src} src={item.src} controls
|
||||
sx={{ width: '100%', maxHeight: 260, borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
|
||||
) : failed ? (
|
||||
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>Image unavailable</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="img" key={item.src} src={item.src} onError={() => setFailed(true)}
|
||||
sx={{ width: '100%', maxHeight: 260, objectFit: 'contain', borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
|
||||
)}
|
||||
{items.length > 1 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mt: 0.5 }}>
|
||||
<IconButton size="small" disabled={index === 0} onClick={() => setIndex((i) => i - 1)}>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{index + 1} / {items.length}</Typography>
|
||||
<IconButton size="small" disabled={index === items.length - 1} onClick={() => setIndex((i) => i + 1)}>
|
||||
<ChevronRightIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const RecordDetailsDialog: React.FC<RecordDetailsDialogProps> = ({ ticket, liveIssueState, onClose }) => {
|
||||
const [details, setDetails] = useState<TicketRecordDetails | null>(null);
|
||||
const [recordGone, setRecordGone] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticket) return;
|
||||
setDetails(null);
|
||||
setRecordGone(false);
|
||||
setLoading(true);
|
||||
adminService.getTicketRecordDetails(ticket.id)
|
||||
.then(setDetails)
|
||||
.catch((err: any) => {
|
||||
if (err?.response?.status === 404) setRecordGone(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ticket?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!ticket) return null;
|
||||
const rec = details?.record;
|
||||
const isRect = details?.recordType === 'rectification';
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
Record #{ticket.anomaly_id}
|
||||
{ticket.org_db_name && <Chip size="small" label={ticket.org_db_name} sx={{ ml: 1 }} variant="outlined" />}
|
||||
{details && <Chip size="small" label={details.recordType} sx={{ ml: 1 }} variant="outlined" />}
|
||||
{ticket.resolved_at
|
||||
? <Chip size="small" label="Resolved" color="success" sx={{ ml: 1 }} />
|
||||
: rec?.under_review === 1 && <Chip size="small" label="Under Review" color="warning" sx={{ ml: 1 }} />}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>}
|
||||
{recordGone && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
The record no longer exists in the org database (hard-deleted or the table was recreated). Ticket details below.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{rec && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, mb: 2 }}>
|
||||
<Field label="Asset">{rec.Asset?.replace(/_/g, ' ')}</Field>
|
||||
<Field label="Plaza">{rec.Plaza}</Field>
|
||||
<Field label="Chainage">{rec.Chainage}</Field>
|
||||
<Field label="Side">{rec.Side}</Field>
|
||||
<Field label="Position">{rec.Pos}</Field>
|
||||
{isRect && <Field label="Road">{rec.road}</Field>}
|
||||
<Field label="Video date">{rec.video_date ? new Date(rec.video_date).toLocaleString() : null}</Field>
|
||||
<Field label="Created">{rec.Created_on ? new Date(rec.Created_on).toLocaleString() : null}</Field>
|
||||
<Field label="Audit state">
|
||||
{rec.deleted === 1 ? 'Deleted' : rec.IsAudited === 1 ? `Audited (${rec.Audit_value || '—'})` : 'Pending'}
|
||||
</Field>
|
||||
<Field label="Comments">{rec.Comments}</Field>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1 }}>
|
||||
<MediaImage label="Master image" src={masterImageUrl(rec.Frame_Master, isRect)} />
|
||||
{/* Carousel holds just the anomaly image + playable video;
|
||||
extra images render as a static row below. */}
|
||||
<MediaCarousel
|
||||
items={[
|
||||
...(testImageUrl(rec.Frame_Test)
|
||||
? [{ kind: 'image' as const, src: testImageUrl(rec.Frame_Test)!, label: 'Anomaly image' }]
|
||||
: []),
|
||||
...(testImageUrl(rec.video_url)
|
||||
? [{ kind: 'video' as const, src: testImageUrl(rec.video_url)!, label: 'Video' }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{rec.Extra_Image && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1 }}>
|
||||
{rec.Extra_Image.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.map((src, i) => (
|
||||
<MediaImage key={i} label={`Extra image ${i + 1}`} src={`${TEST_IMAGE_PREFIX}${src}`} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Dev ticket</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, mb: 1.5 }}>
|
||||
<Field label="Dev">@{ticket.dev_handle}</Field>
|
||||
<Field label="Reporter">{ticket.reporter}</Field>
|
||||
<Field label="Reported on">{new Date(ticket.created_at).toLocaleString()}</Field>
|
||||
<Field label="RocketChat">{ticket.rocketchat_sent ? 'Sent' : ticket.rocketchat_error || 'Not sent'}</Field>
|
||||
<Field label="Git issue">
|
||||
{ticket.git_issue_url ? (
|
||||
<Link href={ticket.git_issue_url} target="_blank" rel="noopener noreferrer">
|
||||
#{ticket.git_issue_number}{liveIssueState ? ` (${liveIssueState})` : ''}
|
||||
</Link>
|
||||
) : ticket.git_status}
|
||||
</Field>
|
||||
{ticket.resolved_at && (
|
||||
<Field label="Resolved">{`${ticket.resolved_by || '—'} · ${new Date(ticket.resolved_at).toLocaleString()}`}</Field>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>Message</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', maxHeight: 180, overflowY: 'auto' }}>
|
||||
{ticket.message_text}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
234
src/pages/admin/UserManagement.tsx
Normal file
234
src/pages/admin/UserManagement.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem,
|
||||
Select, FormControl, InputLabel, Switch, FormControlLabel, CircularProgress, Chip,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { AdminUser, Role } from '../../api/adminService';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
interface Organization {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
export const UserManagement: React.FC = () => {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ username: '', email: '', name: '', password: '', role_id: '', org_id: '', db_name: '' });
|
||||
|
||||
const [editUser, setEditUser] = useState<AdminUser | null>(null);
|
||||
const [editForm, setEditForm] = useState({ username: '', role_id: '', disabled: false });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersRes, rolesRes, orgsRes] = await Promise.all([
|
||||
adminService.getUsers(),
|
||||
adminService.getRoles(),
|
||||
accountService.getOrganizations(),
|
||||
]);
|
||||
setUsers(usersRes);
|
||||
setRoles(rolesRes);
|
||||
setOrganizations((orgsRes as any) || []);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load users.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleOrgChange = (orgId: string | number, setter: (v: any) => void, current: any) => {
|
||||
// MUI Select passes e.target.value through with its original type - org_id
|
||||
// comes back as a number from the API, so compare both sides as strings.
|
||||
const org = organizations.find((o) => String(o.org_id) === String(orgId));
|
||||
setter({ ...current, org_id: orgId, db_name: org?.db_name || '' });
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newUser.username || !newUser.password || !newUser.role_id || !newUser.db_name) {
|
||||
useToastStore.getState().showToast('Username, password, role and org are required.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminService.createUser({
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
name: newUser.name,
|
||||
password: newUser.password,
|
||||
role_id: Number(newUser.role_id),
|
||||
org_id: Number(newUser.org_id),
|
||||
db_name: newUser.db_name,
|
||||
});
|
||||
useToastStore.getState().showToast('User created.', 'success');
|
||||
setAddOpen(false);
|
||||
setNewUser({ username: '', email: '', name: '', password: '', role_id: '', org_id: '', db_name: '' });
|
||||
load();
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.status === 409 ? 'That username is already taken.' : 'Failed to create user.';
|
||||
useToastStore.getState().showToast(message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (user: AdminUser) => {
|
||||
setEditUser(user);
|
||||
setEditForm({ username: user.username, role_id: String(user.role_id), disabled: user.disabled });
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editUser) return;
|
||||
try {
|
||||
await adminService.updateUser(editUser.user_id, {
|
||||
username: editForm.username,
|
||||
role_id: Number(editForm.role_id),
|
||||
disabled: editForm.disabled,
|
||||
});
|
||||
useToastStore.getState().showToast('User updated.', 'success');
|
||||
setEditUser(null);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.status === 409 ? 'That username is already taken.' : 'Failed to update user.';
|
||||
useToastStore.getState().showToast(message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter((u) => {
|
||||
if (!search.trim()) return true;
|
||||
const needle = search.trim().toLowerCase();
|
||||
return (
|
||||
u.username.toLowerCase().includes(needle) ||
|
||||
u.email?.toLowerCase().includes(needle) ||
|
||||
u.name?.toLowerCase().includes(needle) ||
|
||||
u.role_name.toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label="Search users"
|
||||
placeholder="Username, email, name or role"
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 280 }}
|
||||
/>
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)}>Add User</Button>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Org (DB)</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No users match "{search}".
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredUsers.map((u) => (
|
||||
<TableRow key={u.user_id}>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>{u.name}</TableCell>
|
||||
<TableCell>{u.role_name}</TableCell>
|
||||
<TableCell>{u.db_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={u.disabled ? 'Disabled' : 'Active'}
|
||||
color={u.disabled ? 'default' : 'success'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button size="small" onClick={() => openEdit(u)}>Edit</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Add User Dialog */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField label="Username" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} fullWidth />
|
||||
<TextField label="Email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} fullWidth />
|
||||
<TextField label="Name" value={newUser.name} onChange={(e) => setNewUser({ ...newUser, name: e.target.value })} fullWidth />
|
||||
<TextField label="Password" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} fullWidth />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select label="Role" value={newUser.role_id} onChange={(e) => setNewUser({ ...newUser, role_id: e.target.value })}>
|
||||
{roles.map((r) => <MenuItem key={r.id} value={r.id}>{r.role_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Organization</InputLabel>
|
||||
<Select
|
||||
label="Organization"
|
||||
value={newUser.org_id}
|
||||
onChange={(e) => handleOrgChange(e.target.value, setNewUser, newUser)}
|
||||
>
|
||||
{organizations.map((o) => <MenuItem key={o.org_id} value={o.org_id}>{o.org_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog open={!!editUser} onClose={() => setEditUser(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField label="Username" value={editForm.username} onChange={(e) => setEditForm({ ...editForm, username: e.target.value })} fullWidth />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select label="Role" value={editForm.role_id} onChange={(e) => setEditForm({ ...editForm, role_id: e.target.value })}>
|
||||
{roles.map((r) => <MenuItem key={r.id} value={r.id}>{r.role_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!editForm.disabled} onChange={(e) => setEditForm({ ...editForm, disabled: !e.target.checked })} />}
|
||||
label={editForm.disabled ? 'Disabled' : 'Active'}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditUser(null)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleUpdate}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
130
src/pages/audit-session/ZoomableImage.tsx
Normal file
130
src/pages/audit-session/ZoomableImage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
|
||||
const MIN = 1;
|
||||
const MAX = 6;
|
||||
const WHEEL_STEP = 0.3;
|
||||
const BUTTON_STEP = 0.5;
|
||||
|
||||
interface ZoomableImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
// Inspection zoom: scroll wheel zooms toward the cursor and, while zoomed,
|
||||
// moving the mouse pans the view (the transform-origin tracks the pointer).
|
||||
// Fills its parent; the parent should be position:relative + overflow:hidden.
|
||||
export const ZoomableImage: React.FC<ZoomableImageProps> = ({ src, alt, fallback }) => {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [origin, setOrigin] = useState({ x: 50, y: 50 });
|
||||
|
||||
// Reset when the image itself changes (navigating records / carousel).
|
||||
useEffect(() => {
|
||||
setZoom(1);
|
||||
setOrigin({ x: 50, y: 50 });
|
||||
}, [src]);
|
||||
|
||||
// Native, non-passive wheel listener - React's onWheel is passive, so
|
||||
// preventDefault (to stop the page scrolling while zooming) is ignored there.
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
setOrigin({
|
||||
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((e.clientY - rect.top) / rect.height) * 100,
|
||||
});
|
||||
setZoom((z) => Math.min(MAX, Math.max(MIN, +(z + (e.deltaY < 0 ? WHEEL_STEP : -WHEEL_STEP)).toFixed(2))));
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const handleMove = (e: React.MouseEvent) => {
|
||||
if (zoom === 1) return; // no panning at fit scale
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setOrigin({
|
||||
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((e.clientY - rect.top) / rect.height) * 100,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={wrapRef}
|
||||
onMouseMove={handleMove}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: zoom > 1 ? 'move' : 'zoom-in',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: `${origin.x}% ${origin.y}%`,
|
||||
transition: 'transform 0.08s ease-out',
|
||||
}}
|
||||
onError={(e) => {
|
||||
if (!e.currentTarget.src.includes(fallback)) {
|
||||
e.currentTarget.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 1, zIndex: 10 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setZoom((z) => Math.min(MAX, +(z + BUTTON_STEP).toFixed(2)))}
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setZoom((z) => Math.max(MIN, +(z - BUTTON_STEP).toFixed(2)))}
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{zoom > 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{zoom.toFixed(1)}×
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
590
src/pages/clients/ClientsOverview.tsx
Normal file
590
src/pages/clients/ClientsOverview.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo, Fragment } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, TextField, Button, CircularProgress, FormControl, Select, MenuItem,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Collapse,
|
||||
} from '@mui/material';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
} from 'recharts';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
import { auditSummaryService } from '../../api/auditSummaryService';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { ClientOverviewOrg } from '../../api/adminService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
import { AssetMultiSelect } from '../../components/common/AssetMultiSelect';
|
||||
|
||||
interface ClientBarData {
|
||||
client: string;
|
||||
org_id: string | number;
|
||||
db_name: string;
|
||||
pending: number;
|
||||
deleted: number;
|
||||
audited: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface SiteOption {
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
}
|
||||
|
||||
const todayStr = () => new Date().toISOString().split('T')[0];
|
||||
const pastWeekStr = () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 7);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// Mirrors the site-parsing logic in MainLayout.tsx — /Master/site's response shape
|
||||
// varies (responseData[0].records, nested sub_sites, or a plain array).
|
||||
const parseSites = (sitesRes: any): SiteOption[] => {
|
||||
let parsedSites: any[] = [];
|
||||
if (sitesRes?.responseData?.[0]?.records) {
|
||||
parsedSites = sitesRes.responseData[0].records;
|
||||
if (parsedSites.length === 1 && Array.isArray(parsedSites[0])) {
|
||||
parsedSites = parsedSites[0];
|
||||
} else if (parsedSites.length > 0 && Array.isArray(parsedSites[0])) {
|
||||
parsedSites = parsedSites.flat();
|
||||
}
|
||||
} else if (Array.isArray(sitesRes)) {
|
||||
parsedSites = sitesRes;
|
||||
} else if (sitesRes?.data && Array.isArray(sitesRes.data)) {
|
||||
parsedSites = sitesRes.data;
|
||||
}
|
||||
|
||||
return parsedSites
|
||||
.map((site) => {
|
||||
const site_id = site.site_id || (site.sub_sites || []).map((s: any) => s.site_id).filter(Boolean).join(',');
|
||||
const site_name = site.site_name || site.name || site.siteName || site_id;
|
||||
return { site_id, site_name };
|
||||
})
|
||||
.filter((s) => s.site_id);
|
||||
};
|
||||
|
||||
export const ClientsOverview: React.FC = () => {
|
||||
const [fromDate, setFromDate] = useState(pastWeekStr());
|
||||
const [toDate, setToDate] = useState(todayStr());
|
||||
const [isRectificationEnabled, setIsRectificationEnabled] = useState(false);
|
||||
const [chartData, setChartData] = useState<ClientBarData[]>([]);
|
||||
const [sitesByOrg, setSitesByOrg] = useState<Record<string, SiteOption[]>>({});
|
||||
const [selectedClient, setSelectedClient] = useState('All Clients');
|
||||
const [selectedSite, setSelectedSite] = useState('All Sites');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [siteSummary, setSiteSummary] = useState<ClientBarData | null>(null);
|
||||
const [siteLoading, setSiteLoading] = useState(false);
|
||||
const [siteError, setSiteError] = useState<string | null>(null);
|
||||
|
||||
const currentOrg = useMemo(
|
||||
() => chartData.find((c) => c.client === selectedClient) || null,
|
||||
[chartData, selectedClient]
|
||||
);
|
||||
|
||||
const availableSites = useMemo(
|
||||
() => (currentOrg ? sitesByOrg[currentOrg.org_id] || [] : []),
|
||||
[currentOrg, sitesByOrg]
|
||||
);
|
||||
|
||||
// Reset the site filter whenever the client changes — a site from the
|
||||
// previous org shouldn't stay selected against a different org's list.
|
||||
useEffect(() => {
|
||||
setSelectedSite('All Sites');
|
||||
}, [selectedClient]);
|
||||
|
||||
const fetchAllClients = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const orgs: any[] = (await accountService.getOrganizations()) as any;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
(orgs || []).map(async (org) => {
|
||||
const sitesRes = await activityFeedsService.getOrganizationSites(org.org_id);
|
||||
const sites = parseSites(sitesRes);
|
||||
setSitesByOrg((prev) => ({ ...prev, [org.org_id]: sites }));
|
||||
|
||||
const siteIds = sites.map((s) => s.site_id).join(',');
|
||||
if (!siteIds) {
|
||||
return { client: org.org_name, org_id: org.org_id, db_name: org.db_name, pending: 0, deleted: 0, audited: 0, total: 0 };
|
||||
}
|
||||
const summary = await auditSummaryService.getSummary({
|
||||
dbName: org.db_name,
|
||||
site_id: siteIds,
|
||||
isRectificationEnabled,
|
||||
fromDate,
|
||||
toDate,
|
||||
});
|
||||
return {
|
||||
client: org.org_name,
|
||||
org_id: org.org_id,
|
||||
db_name: org.db_name,
|
||||
pending: summary.counts.pending,
|
||||
deleted: summary.counts.deleted,
|
||||
audited: summary.counts.audited,
|
||||
total: summary.counts.total,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const data: ClientBarData[] = results.map((r, i) =>
|
||||
r.status === 'fulfilled'
|
||||
? r.value
|
||||
: { client: orgs[i]?.org_name || `Client ${i}`, org_id: orgs[i]?.org_id, db_name: orgs[i]?.db_name, pending: 0, deleted: 0, audited: 0, total: 0 }
|
||||
);
|
||||
setChartData(data);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load client summary', err);
|
||||
setError('Failed to load client data. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isRectificationEnabled, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllClients();
|
||||
}, [fetchAllClients]);
|
||||
|
||||
// Drill down to a single site within the selected client — a fresh,
|
||||
// site-scoped call, since the org-level fetch above only has aggregates.
|
||||
useEffect(() => {
|
||||
if (selectedClient === 'All Clients' || selectedSite === 'All Sites' || !currentOrg) {
|
||||
setSiteSummary(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const fetchSite = async () => {
|
||||
setSiteLoading(true);
|
||||
setSiteError(null);
|
||||
try {
|
||||
const summary = await auditSummaryService.getSummary({
|
||||
dbName: currentOrg.db_name,
|
||||
site_id: selectedSite,
|
||||
isRectificationEnabled,
|
||||
fromDate,
|
||||
toDate,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const siteName = availableSites.find((s) => s.site_id === selectedSite)?.site_name || selectedSite;
|
||||
setSiteSummary({
|
||||
client: siteName,
|
||||
org_id: currentOrg.org_id,
|
||||
db_name: currentOrg.db_name,
|
||||
pending: summary.counts.pending,
|
||||
deleted: summary.counts.deleted,
|
||||
audited: summary.counts.audited,
|
||||
total: summary.counts.total,
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('Failed to load site summary', err);
|
||||
setSiteError('Failed to load site data. Please try again.');
|
||||
} finally {
|
||||
if (!cancelled) setSiteLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSite();
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedClient, selectedSite, currentOrg, availableSites, isRectificationEnabled, fromDate, toDate]);
|
||||
|
||||
const displayedData = useMemo(() => {
|
||||
if (selectedClient === 'All Clients') return chartData;
|
||||
if (selectedSite === 'All Sites') return chartData.filter((c) => c.client === selectedClient);
|
||||
return siteSummary ? [siteSummary] : [];
|
||||
}, [chartData, selectedClient, selectedSite, siteSummary]);
|
||||
|
||||
const effectiveLoading = loading || (selectedSite !== 'All Sites' && siteLoading);
|
||||
const effectiveError = error || (selectedSite !== 'All Sites' ? siteError : null);
|
||||
|
||||
// Totals reflect the dropdowns: sum of every client, just the selected
|
||||
// client, or just the selected site's counts.
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
displayedData.reduce(
|
||||
(acc, c) => ({
|
||||
pending: acc.pending + c.pending,
|
||||
deleted: acc.deleted + c.deleted,
|
||||
audited: acc.audited + c.audited,
|
||||
total: acc.total + c.total,
|
||||
}),
|
||||
{ pending: 0, deleted: 0, audited: 0, total: 0 }
|
||||
),
|
||||
[displayedData]
|
||||
);
|
||||
|
||||
const totalsLabel = selectedSite !== 'All Sites'
|
||||
? (availableSites.find((s) => s.site_id === selectedSite)?.site_name || selectedSite)
|
||||
: selectedClient;
|
||||
|
||||
// Per-org / per-site record counts, independent of the chart above's date
|
||||
// range and single-site drill-down. All-time by default (empty date
|
||||
// fields), optionally scoped by a date range and/or one-or-more assets.
|
||||
const [overviewType, setOverviewType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [overviewDateFrom, setOverviewDateFrom] = useState('');
|
||||
const [overviewDateTo, setOverviewDateTo] = useState('');
|
||||
const [overviewAssetIds, setOverviewAssetIds] = useState<number[]>([]);
|
||||
const [overview, setOverview] = useState<ClientOverviewOrg[]>([]);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [expandedOrgs, setExpandedOrgs] = useState<Set<number>>(new Set());
|
||||
|
||||
// Two independently-editable date fields can produce an inverted range
|
||||
// that silently matches zero rows - normalize the order and extend "to"
|
||||
// to end-of-day, same fix as Allocation.tsx/AuditHistory.tsx/DevTickets.tsx.
|
||||
const [effectiveOverviewFrom, effectiveOverviewTo] = (() => {
|
||||
if (!overviewDateFrom || !overviewDateTo) {
|
||||
return [overviewDateFrom || undefined, overviewDateTo ? `${overviewDateTo}T23:59:59` : undefined];
|
||||
}
|
||||
return overviewDateFrom <= overviewDateTo
|
||||
? [overviewDateFrom, `${overviewDateTo}T23:59:59`]
|
||||
: [overviewDateTo, `${overviewDateFrom}T23:59:59`];
|
||||
})();
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
setOverviewLoading(true);
|
||||
try {
|
||||
const result = await adminService.getClientsOverview({
|
||||
type: overviewType,
|
||||
dateFrom: effectiveOverviewFrom,
|
||||
dateTo: effectiveOverviewTo,
|
||||
assetIds: overviewAssetIds,
|
||||
});
|
||||
setOverview(result.clients);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load client records overview.', 'error');
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
}, [overviewType, effectiveOverviewFrom, effectiveOverviewTo, overviewAssetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOverview();
|
||||
}, [fetchOverview]);
|
||||
|
||||
const toggleOrgExpanded = (orgId: number) => {
|
||||
setExpandedOrgs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(orgId)) next.delete(orgId);
|
||||
else next.add(orgId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const overviewTotals = useMemo(
|
||||
() =>
|
||||
overview.reduce(
|
||||
(acc, o) => ({
|
||||
total: acc.total + o.total,
|
||||
audited: acc.audited + o.audited,
|
||||
deleted: acc.deleted + o.deleted,
|
||||
nonAudited: acc.nonAudited + o.nonAudited,
|
||||
}),
|
||||
{ total: 0, audited: 0, deleted: 0, nonAudited: 0 }
|
||||
),
|
||||
[overview]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', maxWidth: '100vw', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.5, bgcolor: '#0b1121', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label="From"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label="To"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' },
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setIsRectificationEnabled(!isRectificationEnabled)}
|
||||
sx={{
|
||||
bgcolor: isRectificationEnabled ? '#ff4d4f' : 'rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
'&:hover': { bgcolor: isRectificationEnabled ? '#ff7875' : 'rgba(255,255,255,0.2)' },
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
{isRectificationEnabled ? 'RECTIFICATION' : 'AUDITS'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={fetchAllClients}
|
||||
sx={{ borderColor: '#334155', color: '#94a3b8', textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<FormControl size="small" sx={{ bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2, minWidth: 180 }}>
|
||||
<Select
|
||||
value={selectedClient}
|
||||
onChange={(e) => setSelectedClient(e.target.value as string)}
|
||||
sx={{ '& .MuiSelect-select': { py: 1, px: 2 }, border: 'none', '& fieldset': { border: 'none' }, fontWeight: 'bold', color: '#fff' }}
|
||||
>
|
||||
<MenuItem value="All Clients">All Clients</MenuItem>
|
||||
{chartData.map((c) => (
|
||||
<MenuItem key={c.client} value={c.client}>{c.client}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedClient !== 'All Clients' && (
|
||||
<FormControl size="small" sx={{ bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2, minWidth: 180 }}>
|
||||
<Select
|
||||
value={selectedSite}
|
||||
onChange={(e) => setSelectedSite(e.target.value as string)}
|
||||
sx={{ '& .MuiSelect-select': { py: 1, px: 2 }, border: 'none', '& fieldset': { border: 'none' }, fontWeight: 'bold', color: '#fff' }}
|
||||
>
|
||||
<MenuItem value="All Sites">All Sites</MenuItem>
|
||||
{availableSites.map((s) => (
|
||||
<MenuItem key={s.site_id} value={s.site_id}>{s.site_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, pt: 2, pb: 2, width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
<Paper sx={{ flex: 1, p: 3, bgcolor: '#422006', border: '1px solid #713f12', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Total Pending ({totalsLabel})</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#eab308', fontWeight: 'bold' }}>
|
||||
{effectiveLoading ? '...' : totals.pending.toLocaleString()}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ flex: 1, p: 3, bgcolor: '#450a0a', border: '1px solid #7f1d1d', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Total Deleted ({totalsLabel})</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#ef4444', fontWeight: 'bold' }}>
|
||||
{effectiveLoading ? '...' : totals.deleted.toLocaleString()}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ flex: 1, p: 3, bgcolor: '#052e16', border: '1px solid #14532d', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Total Audited ({totalsLabel})</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#22c55e', fontWeight: 'bold' }}>
|
||||
{effectiveLoading ? '...' : totals.audited.toLocaleString()}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ flex: 1, p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Grand Total ({totalsLabel})</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#3b82f6', fontWeight: 'bold' }}>
|
||||
{effectiveLoading ? '...' : totals.total.toLocaleString()}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ color: '#f8fafc', mb: 2, fontWeight: 'bold' }}>
|
||||
{isRectificationEnabled ? 'Rectification' : 'Audit'} Counts by Client
|
||||
{selectedClient !== 'All Clients' ? ` — ${selectedClient}` : ''}
|
||||
{selectedSite !== 'All Sites' ? ` — ${totalsLabel}` : ''}
|
||||
</Typography>
|
||||
|
||||
{effectiveLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : effectiveError ? (
|
||||
<Typography sx={{ color: '#ef4444', textAlign: 'center', py: 8 }}>{effectiveError}</Typography>
|
||||
) : displayedData.length === 0 ? (
|
||||
<Typography sx={{ color: '#64748b', textAlign: 'center', py: 8 }}>No data found</Typography>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={420}>
|
||||
<BarChart data={displayedData} margin={{ top: 10, right: 20, left: 0, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="client" tick={{ fill: '#94a3b8' }} />
|
||||
<YAxis tick={{ fill: '#94a3b8' }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: 8 }}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: '#94a3b8' }} />
|
||||
<Bar dataKey="pending" name="Pending" fill="#eab308" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="deleted" name="Deleted" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="audited" name="Audited" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2, flexWrap: 'wrap', gap: 1.5 }}>
|
||||
<Typography variant="h6" sx={{ color: '#f8fafc', fontWeight: 'bold' }}>
|
||||
All Clients — Records by Site
|
||||
{(overviewDateFrom || overviewDateTo || overviewAssetIds.length > 0) ? '' : ' (all-time)'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label="From"
|
||||
value={overviewDateFrom}
|
||||
onChange={(e) => setOverviewDateFrom(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' } }}
|
||||
/>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label="To"
|
||||
value={overviewDateTo}
|
||||
onChange={(e) => setOverviewDateTo(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' } }}
|
||||
/>
|
||||
<AssetMultiSelect value={overviewAssetIds} onChange={setOverviewAssetIds} />
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setOverviewType(overviewType === 'audits' ? 'rectification' : 'audits')}
|
||||
sx={{
|
||||
bgcolor: overviewType === 'rectification' ? '#ff4d4f' : 'rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
'&:hover': { bgcolor: overviewType === 'rectification' ? '#ff7875' : 'rgba(255,255,255,0.2)' },
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
{overviewType === 'rectification' ? 'RECTIFICATION' : 'AUDITS'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={fetchOverview}
|
||||
sx={{ borderColor: '#334155', color: '#94a3b8', textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{overviewLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : overview.length === 0 ? (
|
||||
<Typography sx={{ color: '#64748b', textAlign: 'center', py: 8 }}>No data found</Typography>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', width: 40 }} />
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }}>Organization / Site</TableCell>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Total</TableCell>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Audited</TableCell>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Deleted</TableCell>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Non-Audited</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{overview.map((org) => {
|
||||
const isExpanded = expandedOrgs.has(org.org_id);
|
||||
return (
|
||||
<Fragment key={org.org_id}>
|
||||
<TableRow>
|
||||
<TableCell sx={{ borderColor: '#1e293b' }}>
|
||||
{org.sites.length > 0 && (
|
||||
<IconButton size="small" onClick={() => toggleOrgExpanded(org.org_id)} sx={{ color: '#94a3b8' }}>
|
||||
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#f8fafc', borderColor: '#1e293b', fontWeight: 'bold' }}>
|
||||
{org.org_name}
|
||||
{org.error && (
|
||||
<Typography component="span" sx={{ color: '#ef4444', ml: 1, fontSize: '0.75rem' }}>
|
||||
(failed to load: {org.error})
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#3b82f6', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{org.total.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#22c55e', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{org.audited.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#ef4444', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{org.deleted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#eab308', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{org.nonAudited.toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{org.sites.length > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ p: 0, borderColor: '#1e293b' }}>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
{org.sites.map((site) => (
|
||||
<TableRow key={site.site_id}>
|
||||
<TableCell sx={{ borderColor: '#1e293b', width: 40 }} />
|
||||
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b', pl: 4 }}>
|
||||
{site.site_name}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#93c5fd', borderColor: '#1e293b' }} align="right">
|
||||
{site.total.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#86efac', borderColor: '#1e293b' }} align="right">
|
||||
{site.audited.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#fca5a5', borderColor: '#1e293b' }} align="right">
|
||||
{site.deleted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#fde047', borderColor: '#1e293b' }} align="right">
|
||||
{site.nonAudited.toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<TableRow>
|
||||
<TableCell sx={{ borderColor: '#1e293b' }} />
|
||||
<TableCell sx={{ color: '#f8fafc', borderColor: '#1e293b', fontWeight: 'bold' }}>All Clients</TableCell>
|
||||
<TableCell sx={{ color: '#3b82f6', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{overviewTotals.total.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#22c55e', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{overviewTotals.audited.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#ef4444', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{overviewTotals.deleted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#eab308', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{overviewTotals.nonAudited.toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button, Paper } from '@mui/material';
|
||||
import { Box, Typography, Button, Paper, LinearProgress, Chip } from '@mui/material';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
import { TopFilterBar } from '../../components/common/TopFilterBar';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
|
||||
type UserStats = Awaited<ReturnType<typeof activityFeedsService.getUserStats>>;
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const {
|
||||
selectedSite, auditStatus, filterFromDate, filterTillDate,
|
||||
filterMinChainage, filterMaxChainage,
|
||||
selectedAssetIds, dashboardCache, setDashboardCache,
|
||||
searchAnomalyId, filterPlaza, isRectificationEnabled,
|
||||
tabs, selectedTabIndex
|
||||
tabs, selectedTabIndex, sortBy, sortDir
|
||||
} = useFeedStore();
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -24,10 +26,12 @@ export const Dashboard: React.FC = () => {
|
||||
const isRectActive = isRectificationEnabled || activeTab.isRectificationTab;
|
||||
|
||||
// Build a key that uniquely identifies the current filter state
|
||||
const currentFilterKey = `${selectedSiteId}|${auditStatus}|${filterFromDate}|${filterTillDate}|${assetIdsKey}|${filterMinChainage}|${filterMaxChainage}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeDate}`;
|
||||
const currentFilterKey = `${selectedSiteId}|${auditStatus}|${filterFromDate}|${filterTillDate}|${assetIdsKey}|${filterMinChainage}|${filterMaxChainage}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeDate}|${sortBy}|${sortDir}`;
|
||||
|
||||
// Initialise stats from cache immediately (no flicker on tab switch)
|
||||
const [stats, setStats] = useState(() =>
|
||||
// Session-start info (which page the audit session should open on) - still
|
||||
// derived from the filtered feed, same as before. The headline stats below
|
||||
// no longer come from this; they come from /anomaly/user-stats.
|
||||
const [sessionInfo, setSessionInfo] = useState(() =>
|
||||
dashboardCache.stats ?? {
|
||||
globalTotal: 0,
|
||||
batchTotal: 0,
|
||||
@@ -37,25 +41,39 @@ export const Dashboard: React.FC = () => {
|
||||
firstPendingIndex: -1,
|
||||
}
|
||||
);
|
||||
// Only show loading spinner if there's no cached result for these filters
|
||||
const [loading, setLoading] = useState(dashboardCache.stats === null);
|
||||
|
||||
// User-scoped stats: overall workload, today's activity, batches of 50.
|
||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setStatsLoading(true);
|
||||
activityFeedsService.getUserStats(isRectActive, sortBy, sortDir)
|
||||
.then((s) => { if (!cancelled) setUserStats(s); })
|
||||
.catch((err) => console.error('Failed to load user stats', err))
|
||||
.finally(() => { if (!cancelled) setStatsLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
// Batch numbering follows the chosen sort - refetch when it changes so
|
||||
// "batch N" keeps matching the N-th session page.
|
||||
}, [isRectActive, sortBy, sortDir]);
|
||||
|
||||
useEffect(() => {
|
||||
// CACHE HIT: filters unchanged since last fetch — skip API call entirely
|
||||
if (dashboardCache.stats !== null && currentFilterKey === `${dashboardCache.siteId}|${dashboardCache.auditStatus}|${dashboardCache.fromDate}|${dashboardCache.tillDate}|${dashboardCache.assetIds}|${dashboardCache.minChainage}|${dashboardCache.maxChainage}|${dashboardCache.searchAnomalyId}|${dashboardCache.filterPlaza}|${dashboardCache.isRectificationEnabled ?? false}|${dashboardCache.date ?? ''}`) {
|
||||
setStats(dashboardCache.stats);
|
||||
setLoading(false);
|
||||
if (dashboardCache.stats !== null && currentFilterKey === `${dashboardCache.siteId}|${dashboardCache.auditStatus}|${dashboardCache.fromDate}|${dashboardCache.tillDate}|${dashboardCache.assetIds}|${dashboardCache.minChainage}|${dashboardCache.maxChainage}|${dashboardCache.searchAnomalyId}|${dashboardCache.filterPlaza}|${dashboardCache.isRectificationEnabled ?? false}|${dashboardCache.date ?? ''}|${dashboardCache.sortBy ?? ''}|${dashboardCache.sortDir ?? 'asc'}`) {
|
||||
setSessionInfo(dashboardCache.stats);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
const fetchSessionInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await activityFeedsService.getHistory({
|
||||
date: activeDate,
|
||||
pageNo: 0,
|
||||
pageSize: 200,
|
||||
sortByDateAsc: false,
|
||||
sortBy,
|
||||
sortDir,
|
||||
isRectificationEnabled: isRectActive,
|
||||
auditStatus: auditStatus,
|
||||
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
|
||||
@@ -80,7 +98,7 @@ export const Dashboard: React.FC = () => {
|
||||
batchAnomaliesFound: anomalies.filter((a: any) => a.Audit_status === 1).length,
|
||||
firstPendingIndex: anomalies.findIndex((a: any) => a.IsAudited === 0)
|
||||
};
|
||||
setStats(newStats);
|
||||
setSessionInfo(newStats);
|
||||
// Persist to store cache so re-mounting the Dashboard skips this fetch
|
||||
setDashboardCache({
|
||||
siteId: selectedSiteId,
|
||||
@@ -94,95 +112,113 @@ export const Dashboard: React.FC = () => {
|
||||
filterPlaza,
|
||||
isRectificationEnabled: isRectActive,
|
||||
date: activeDate,
|
||||
sortBy,
|
||||
sortDir,
|
||||
stats: newStats,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard stats', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.error('Failed to load dashboard session info', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
fetchSessionInfo();
|
||||
}, [currentFilterKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const overall = userStats?.overall;
|
||||
const today = userStats?.today;
|
||||
const batches = userStats?.batches ?? [];
|
||||
// The active batch is the first one with anything left to do.
|
||||
const activeBatchIdx = batches.findIndex((b) => b.pending > 0);
|
||||
const scopeLabel = userStats?.scoped ? 'your records, all sites' : 'org-wide, all sites';
|
||||
|
||||
const statCard = (label: string, value: number | undefined, color: string, bg: string, border: string, caption: string) => (
|
||||
<Paper sx={{ flex: 1, p: 3, bgcolor: bg, border: `1px solid ${border}`, borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>{label}</Typography>
|
||||
<Typography variant="h4" sx={{ color, fontWeight: 'bold' }}>
|
||||
{statsLoading || value === undefined ? '...' : value.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5, display: 'block' }}>
|
||||
{caption}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', maxWidth: '100vw', display: 'flex', flexDirection: 'column' }}>
|
||||
// MainLayout's <main> is height:100vh + overflow:hidden (by design - each
|
||||
// page owns its own scroll region; see AdminConsole.tsx for the same fix).
|
||||
// The batches list can be far taller than the viewport, so this page needs
|
||||
// its own bounded scrollable container.
|
||||
<Box sx={{ width: '100%', maxWidth: '100vw', display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
|
||||
<TopFilterBar />
|
||||
<Box sx={{ px: 2, pt: 2, pb: 4, width: '100%' }}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
|
||||
{/* Stats Row */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
{/* Active Batch */}
|
||||
<Paper sx={{
|
||||
flex: 1, p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Active Batch</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#3b82f6', fontWeight: 'bold' }}>
|
||||
{loading ? '...' : stats.batchTotal.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5, display: 'block' }}>
|
||||
of {loading ? '...' : stats.globalTotal.toLocaleString()} total items on site
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Audited */}
|
||||
<Paper sx={{
|
||||
flex: 1, p: 3, bgcolor: '#052e16', border: '1px solid #14532d', borderRadius: 2
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Audited</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#22c55e', fontWeight: 'bold' }}>
|
||||
{loading ? '...' : stats.batchAudited.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#475569', mt: 0.5, display: 'block' }}>
|
||||
in current batch
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Pending */}
|
||||
<Paper sx={{
|
||||
flex: 1, p: 3, bgcolor: '#422006', border: '1px solid #713f12', borderRadius: 2
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Pending</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#eab308', fontWeight: 'bold' }}>
|
||||
{loading ? '...' : stats.batchPending.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#713f12', mt: 0.5, display: 'block' }}>
|
||||
in current batch
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Anomalies Found */}
|
||||
<Paper sx={{
|
||||
flex: 1, p: 3, bgcolor: '#450a0a', border: '1px solid #7f1d1d', borderRadius: 2
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Anomalies Found</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#ef4444', fontWeight: 'bold' }}>
|
||||
{loading ? '...' : stats.batchAnomaliesFound.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#7f1d1d', mt: 0.5, display: 'block' }}>
|
||||
in current batch
|
||||
</Typography>
|
||||
</Paper>
|
||||
{/* Overall stats — the logged-in user's whole workload */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
{statCard('Total Workload', overall?.total, '#3b82f6', '#0f172a', '#1e293b', `overall (${scopeLabel})`)}
|
||||
{statCard('Audited', overall?.audited, '#22c55e', '#052e16', '#14532d', `overall (${scopeLabel})`)}
|
||||
{statCard('Pending', overall?.pending, '#eab308', '#422006', '#713f12', `overall (${scopeLabel})`)}
|
||||
{statCard('Anomalies Found', overall?.anomaliesFound, '#ef4444', '#450a0a', '#7f1d1d', `overall (${scopeLabel})`)}
|
||||
</Box>
|
||||
|
||||
{/* Today's stats */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
{statCard('Audited Today', today?.audited, '#22c55e', '#0f172a', '#1e293b', userStats?.scoped ? 'completed by you today' : 'completed today (org-wide)')}
|
||||
{statCard('Anomalies Found Today', today?.anomaliesFound, '#ef4444', '#0f172a', '#1e293b', userStats?.scoped ? 'true anomalies you confirmed today' : 'true anomalies confirmed today (org-wide)')}
|
||||
</Box>
|
||||
|
||||
{/* Batches of 50 */}
|
||||
<Paper sx={{ p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2, mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ color: '#f8fafc', fontWeight: 'bold', mb: 2 }}>
|
||||
Batches <Typography component="span" variant="caption" sx={{ color: '#64748b' }}>({userStats?.batchSize ?? 50} items each, in session order)</Typography>
|
||||
</Typography>
|
||||
{statsLoading ? (
|
||||
<Typography sx={{ color: '#64748b' }}>Loading…</Typography>
|
||||
) : batches.length === 0 ? (
|
||||
<Typography sx={{ color: '#64748b' }}>No items in your workload yet.</Typography>
|
||||
) : (
|
||||
batches.map((b, i) => {
|
||||
const pct = b.total > 0 ? Math.round((b.audited / b.total) * 100) : 0;
|
||||
const isActive = i === activeBatchIdx;
|
||||
return (
|
||||
<Box key={b.batch} sx={{ mb: 1.5, p: 1.5, borderRadius: 1.5, border: `1px solid ${isActive ? '#3b82f6' : '#1e293b'}`, bgcolor: isActive ? 'rgba(59,130,246,0.08)' : 'transparent' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||
<Typography variant="body2" sx={{ color: '#f8fafc', fontWeight: 'bold' }}>
|
||||
Batch {b.batch}
|
||||
</Typography>
|
||||
{isActive && <Chip size="small" label="Active" color="primary" />}
|
||||
{b.pending === 0 && <Chip size="small" label="Complete" sx={{ bgcolor: '#052e16', color: '#22c55e' }} />}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
{b.audited}/{b.total} audited · {b.pending} pending · {b.anomaliesFound} anomalies
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
sx={{
|
||||
height: 8, borderRadius: 4, bgcolor: '#1e293b',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: b.pending === 0 ? '#22c55e' : '#3b82f6', borderRadius: 4 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
|
||||
{/* Start Button */}
|
||||
{/* Start Button — unchanged behavior, driven by the filtered feed */}
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={() => navigate('/audit-session', {
|
||||
state: {
|
||||
isReviewMode: stats.batchPending === 0,
|
||||
startPage: stats.firstPendingIndex !== undefined && stats.firstPendingIndex !== -1 ? Math.floor(stats.firstPendingIndex / 50) : 0
|
||||
isReviewMode: sessionInfo.batchPending === 0,
|
||||
startPage: sessionInfo.firstPendingIndex !== undefined && sessionInfo.firstPendingIndex !== -1 ? Math.floor(sessionInfo.firstPendingIndex / 50) : 0
|
||||
}
|
||||
})}
|
||||
disabled={stats.batchTotal === 0}
|
||||
disabled={sessionInfo.batchTotal === 0}
|
||||
sx={{
|
||||
py: 2,
|
||||
bgcolor: '#3b82f6',
|
||||
@@ -194,11 +230,11 @@ export const Dashboard: React.FC = () => {
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{stats.batchTotal === 0
|
||||
{sessionInfo.batchTotal === 0
|
||||
? 'No Items to Audit'
|
||||
: stats.batchPending === 0
|
||||
? `Start Session — Review ${Math.min(stats.batchTotal, 50)} Audited Items`
|
||||
: `Start Session — Audit Next ${Math.min(stats.batchPending, 50)} Items`}
|
||||
: sessionInfo.batchPending === 0
|
||||
? `Start Session — Review ${Math.min(sessionInfo.batchTotal, 50)} Audited Items`
|
||||
: `Start Session — Audit Next ${Math.min(sessionInfo.batchPending, 50)} Items`}
|
||||
</Button>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { persist } from 'zustand/middleware';
|
||||
import { useFeedStore } from './feedStore';
|
||||
|
||||
export interface User {
|
||||
token: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
email: string;
|
||||
username: string;
|
||||
user_roles: string;
|
||||
role: string;
|
||||
db_name?: string;
|
||||
org_id?: string;
|
||||
user_id?: string | number;
|
||||
@@ -36,12 +36,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
hasRole: (requiredRole: string) => {
|
||||
const { user } = get();
|
||||
if (!user || !user.user_roles) return false;
|
||||
if (!user || !user.role) return false;
|
||||
|
||||
const userRoles = user.user_roles.split(',').map(r => r.trim().toLowerCase());
|
||||
const neededRoles = requiredRole.split(',').map(r => r.trim().toLowerCase());
|
||||
|
||||
return neededRoles.some(role => userRoles.includes(role));
|
||||
return neededRoles.includes(user.role.toLowerCase());
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// Feed/session sort. '' = backend default order (id DESC on the pending
|
||||
// feed). One choice drives the whole chain: feed table, session pages,
|
||||
// dashboard batch numbering and the "continue auditing" start page - they
|
||||
// must all use the same order or batch N stops matching session page N.
|
||||
export type FeedSortBy = '' | 'id' | 'date' | 'chainage' | 'latlong';
|
||||
export type FeedSortDir = 'asc' | 'desc';
|
||||
|
||||
export interface FeedTab {
|
||||
// '' = pending tab; 'YYYY-MM-DD' or 'YYYY-MM-DD_YYYY-MM-DD' (range) = history tab
|
||||
date: string;
|
||||
isRectificationTab: boolean;
|
||||
// History tabs only: show records audited BY the logged-in user
|
||||
auditedByMe?: boolean;
|
||||
totalAnomalyCount: number;
|
||||
anomalies: any[];
|
||||
anomaliesCopy: any[];
|
||||
@@ -26,6 +36,8 @@ interface DashboardCache {
|
||||
filterPlaza: string;
|
||||
isRectificationEnabled?: boolean;
|
||||
date?: string;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
stats: {
|
||||
globalTotal: number;
|
||||
batchTotal: number;
|
||||
@@ -59,6 +71,8 @@ interface FeedState {
|
||||
searchAnomalyId: string;
|
||||
isRectificationEnabled: boolean;
|
||||
isFeedLoading: boolean;
|
||||
sortBy: FeedSortBy;
|
||||
sortDir: FeedSortDir;
|
||||
|
||||
// Actions
|
||||
setTabs: (tabs: FeedTab[]) => void;
|
||||
@@ -84,6 +98,8 @@ interface FeedState {
|
||||
setSearchAnomalyId: (id: string) => void;
|
||||
setIsRectificationEnabled: (val: boolean) => void;
|
||||
setIsFeedLoading: (val: boolean) => void;
|
||||
setSortBy: (val: FeedSortBy) => void;
|
||||
setSortDir: (val: FeedSortDir) => void;
|
||||
resetStore: () => void;
|
||||
}
|
||||
|
||||
@@ -123,6 +139,8 @@ const initialFeedState = {
|
||||
searchAnomalyId: '',
|
||||
isRectificationEnabled: false,
|
||||
isFeedLoading: false,
|
||||
sortBy: '' as FeedSortBy,
|
||||
sortDir: 'asc' as FeedSortDir,
|
||||
};
|
||||
|
||||
export const useFeedStore = create<FeedState>()(
|
||||
@@ -152,6 +170,8 @@ export const useFeedStore = create<FeedState>()(
|
||||
setSearchAnomalyId: (searchAnomalyId) => set({ searchAnomalyId }),
|
||||
setIsRectificationEnabled: (isRectificationEnabled) => set({ isRectificationEnabled }),
|
||||
setIsFeedLoading: (isFeedLoading) => set({ isFeedLoading }),
|
||||
setSortBy: (sortBy) => set({ sortBy }),
|
||||
setSortDir: (sortDir) => set({ sortDir }),
|
||||
addTab: (tab) => set((state) => ({ tabs: [...state.tabs, tab], selectedTabIndex: state.tabs.length })),
|
||||
closeTab: (index) => set((state) => {
|
||||
const newTabs = [...state.tabs];
|
||||
@@ -180,6 +200,8 @@ export const useFeedStore = create<FeedState>()(
|
||||
auditStatus: state.auditStatus,
|
||||
searchAnomalyId: state.searchAnomalyId,
|
||||
isRectificationEnabled: state.isRectificationEnabled,
|
||||
sortBy: state.sortBy,
|
||||
sortDir: state.sortDir,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user