Compare commits

..

20 Commits

Author SHA1 Message Date
a76f92dbdd ui fixes asked by support team 2026-07-22 14:39:45 +05:30
c7c3d46c62 audit session: zoomable images, 0-9 categories, prefilled notes, Next != Skip
- images: scroll-wheel zoom toward the cursor + mouse-follow pan on both
  master and anomaly panes (new ZoomableImage); video keeps native controls
- categories renumbered 0-9 on both lists (first item = 0) for display and
  keyboard shortcut, matching the legacy UI; keyboard resolves by key, not id
- NOTES prefills from the record's existing Comment so the algorithm comment
  is visible/editable and no longer wiped on save
- Enter/primary button is plain Next (record stays pending); only Skip/Tab
  marks Skipped, and Skipped records are excluded from Audit All
  feed table: FEATURES shows Audit_value; verdict pill replaces slider

- FEATURES column now renders the record's Audit_value (was a nonexistent
  field showing N/A)
- ANOMALY True/False column: colored pill (green True / red False / neutral
  when not yet audited) instead of the broken slider; gated on IsAudited so
  pending rows read neutral
2026-07-21 16:04:31 +05:30
ee25bb6065 rectification audit options: TRUE auto-"Others", FALSE limited to true/false others
- rect TRUE: no category menu, verdict saved as "Others" (legacy behavior);
  Save/Enter work without a category, A auto-saves in Quick mode
- rect FALSE: only "true others" / "false others" (keys 1-2); audits safe
  menu no longer selectable in rect mode (closes the "duplicate" soft-delete trap)
- FALSE + "true others" now calls POST /auditor/rectified/false to re-open
  the defect (asset DAMAGED) - wired into single save, Bulk Mode phase 2b,
  and Audit All; per-record errors surfaced in toasts
- audits mode untouched (all changes gated on isRectActive)
2026-07-17 11:27:36 +05:30
c872566e83 dev tickets and issues raised 2026-07-15 14:24:26 +05:30
c911311596 under review records ui and be 2026-07-15 11:14:25 +05:30
08307119da image scanning and asset edit 2026-07-14 14:20:24 +05:30
8c6c26bc8e feat: add admin service, console page, and missing media monitoring module 2026-07-13 23:34:17 +05:30
3cb086d67b sorting for records in the ui 2026-07-13 21:43:10 +05:30
c7cea78f74 build file fixes 2026-07-13 15:23:34 +05:30
4ed551e253 bulk audit and multiselect option in the ui 2026-07-13 15:10:43 +05:30
98eb91af71 feat: implement admin audit history, session management, and dashboard modules with supporting API service layer 2026-07-13 12:22:46 +05:30
e5c2b047d7 feat: implement administrative audit allocation and management dashboard pages with supporting API services 2026-07-12 18:19:50 +05:30
b667fe1082 feat: implement client overview dashboard and admin portal structure with API services and page components 2026-07-09 17:37:54 +05:30
3cf9e2997c feat: create AuditSession page with annotation tools, reporting, and session management 2026-07-08 14:47:45 +05:30
c1e3212e07 client-site based dropdown in the clients tab 2026-07-07 14:29:23 +05:30
89daec453b feat: implement ClientsOverview dashboard with audit summary visualization and data filtering 2026-07-06 18:30:46 +05:30
76888915e7 few tweaks to fix master image issue 2026-07-06 10:47:28 +05:30
9d2acd126c feat: implement audit session module with centralized API service and axios interceptors 2026-06-26 11:36:37 +05:30
fc61b4cdb6 feat: implement activity feed module with debounced filtering, history tracking, and axios service integration 2026-06-24 11:28:37 +05:30
de2b5a808f mvp-1 2026-06-23 10:26:28 +05:30
40 changed files with 6403 additions and 808 deletions

3
.env
View File

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

View File

@@ -1,2 +1,2 @@
VITE_DASHBOARD_API_URL=https://python.seekright.com
VITE_AUDIT_API_URL=https://node-audit.seekright.com
# VITE_AUDIT_API_URL=https://node-audit.seekright.com
VITE_AUDIT_API_URL=https://ms31kqw1-7514.inc1.devtunnels.ms

411
package-lock.json generated
View File

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

View File

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

View File

@@ -43,11 +43,14 @@ 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() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<ToastProvider />
<BrowserRouter>
<Routes>
{/* Public Routes */}
@@ -60,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>

View File

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

View File

@@ -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) => {
getHistoryDates: async (isRectificationEnabled: boolean, siteIds?: string, auditedByMe = false) => {
const response = await axiosClient.get('/api/audit/anomaly/get_history_dates', {
params: { isRectificationEnabled }
params: { isRectificationEnabled, siteIds: siteIds || '', auditedByMe }
});
return response;
},
@@ -34,7 +34,7 @@ export const activityFeedsService = {
if (params.dbName) {
falseParams.dbName = params.dbName;
}
const response: any = await activityFeedsService.getFalseAnomaly(falseParams);
const resultObj = response?.result || response;
return {
@@ -62,18 +62,62 @@ 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/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;
},
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
View 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;
},
};

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

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { useAuthStore } from '../store/authStore';
import { useToastStore } from '../store/toastStore';
// You can move this to an environment variable later
// Base URL is empty because we use specific proxy prefixes in the service calls
@@ -15,20 +16,17 @@ export const axiosClient = axios.create({
// Request Interceptor: Attach token if available
axiosClient.interceptors.request.use(
(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 };
}
// Automatically rewrite local proxy paths to the real URLs from environment variables
// This allows us to remove the Vite/Nginx proxy completely!
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);
}
@@ -38,27 +36,95 @@ 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,
(error) => {
if (!error.response) {
// Network offline or CORS error (status 0 in Angular)
alert('Seems one of the sub-application is offline. Please restart the application again.');
useToastStore.getState().showToast('Network error — one of the services appears to be offline.', 'error');
return Promise.reject(error);
}
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 || 'OOPS! Something wrong has happened. Please refresh the browser and try again.';
alert(message);
const message = data?.responseStatus?.responseMessage || data?.errorMessage || 'Something went wrong. Please try again.';
useToastStore.getState().showToast(message, 'error');
} else if (status === 401) {
console.error('Login issue');
useAuthStore.getState().logout();
window.location.href = '/login'; // Redirect to login
window.location.href = '/login';
} else {
alert('OOPS! Something wrong has happened. Please refresh the browser and try again.');
useToastStore.getState().showToast('Something went wrong. Please refresh and try again.', 'error');
}
return Promise.reject(error);

View 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>
</>
);
};

View File

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

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Alert, Stack } from '@mui/material';
import { useToastStore } from '../../store/toastStore';
export const ToastProvider: React.FC = () => {
const { toasts, removeToast } = useToastStore();
if (toasts.length === 0) return null;
return (
<Stack
spacing={1}
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 99999,
pointerEvents: 'none',
}}
>
{toasts.map((toast) => (
<Alert
key={toast.id}
severity={toast.severity}
variant="filled"
onClose={() => removeToast(toast.id)}
sx={{
minWidth: 280,
maxWidth: 420,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
pointerEvents: 'all',
}}
>
{toast.message}
</Alert>
))}
</Stack>
);
};

View File

@@ -112,33 +112,72 @@ export const TopFilterBar: React.FC = () => {
return `Asset: ${id}`;
};
const [historyDates, setHistoryDates] = useState<any[]>([]);
const [historyDates, setHistoryDates] = useState<any[]>([]); // normal history (isRectificationEnabled=false)
const [rectHistoryDates, setRectHistoryDates] = useState<any[]>([]); // rectification history (isRectificationEnabled=true)
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [loadingHistory, setLoadingHistory] = useState(false);
const [loadingRectHistory, setLoadingRectHistory] = useState(false);
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
const [selectedHistoryDate, setSelectedHistoryDate] = useState('2026-06-12');
// 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('');
const handleHistoryClick = async () => {
// 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, true);
setHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
} catch (err) {
console.error("Failed to load history dates", err);
} finally {
setLoadingHistory(false);
}
};
fetchDates();
}, [selectedSite?.site_id]); // eslint-disable-line react-hooks/exhaustive-deps
const openHistoryDialog = async (mode: 'audits' | 'rect') => {
setHistoryDialogMode(mode);
setHistoryFrom('');
setHistoryTill('');
setIsHistoryDialogOpen(true);
setLoadingHistory(true);
try {
const dates: any = await activityFeedsService.getHistoryDates(true);
setHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
} catch (err) {
console.error("Failed to load history dates", err);
} finally {
setLoadingHistory(false);
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, true);
setRectHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
} catch (err) {
console.error("Failed to load rectification history dates", err);
} finally {
setLoadingRectHistory(false);
}
}
// 'audits' chips come from the historyDates effect (refetched per site).
};
const handleSelectDate = (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: true,
date: dateKey,
isRectificationTab: isRect,
auditedByMe: true,
totalAnomalyCount: 0,
anomalies: [],
anomaliesCopy: [],
@@ -151,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);
};
@@ -193,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)}
@@ -208,6 +243,22 @@ export const TopFilterBar: React.FC = () => {
>
BULK DELETE
</Button>
{/* 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' }}
>
HISTORY
</Button>
)}
<Dialog
open={isHistoryDialogOpen}
onClose={() => setIsHistoryDialogOpen(false)}
@@ -224,58 +275,66 @@ 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 FromTill 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>
<TextField
type="date"
value={selectedHistoryDate}
onChange={(e) => setSelectedHistoryDate(e.target.value)}
fullWidth
sx={{
mb: 3,
'& .MuiOutlinedInput-root': {
color: '#f8fafc',
'& fieldset': { borderColor: '#334155' },
'&:hover fieldset': { borderColor: '#475569' },
'&.Mui-focused fieldset': { borderColor: '#3b82f6' },
},
'& input[type="date"]::-webkit-calendar-picker-indicator': {
filter: 'invert(1)',
cursor: 'pointer',
}
}}
/>
<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={value}
onChange={(e) => setter(e.target.value)}
fullWidth
sx={{
'& .MuiOutlinedInput-root': {
color: '#f8fafc',
'& fieldset': { borderColor: '#334155' },
'&:hover fieldset': { borderColor: '#475569' },
'&.Mui-focused fieldset': { borderColor: '#3b82f6' },
},
'& input[type="date"]::-webkit-calendar-picker-indicator': {
filter: 'invert(1)',
cursor: 'pointer',
}
}}
/>
</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' }}>
{loadingHistory ? (
{(historyDialogMode === 'rect' ? loadingRectHistory : loadingHistory) ? (
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} color="inherit" />
</Box>
) : historyDates.length === 0 ? (
) : (historyDialogMode === 'rect' ? rectHistoryDates : historyDates).length === 0 ? (
<Typography variant="body2" sx={{ color: '#64748b', p: 1 }}>No dates found</Typography>
) : (
historyDates.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',
}}
/>
);
@@ -284,20 +343,20 @@ export const TopFilterBar: React.FC = () => {
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => setIsHistoryDialogOpen(false)}
<Button
onClick={() => setIsHistoryDialogOpen(false)}
sx={{ color: '#94a3b8', textTransform: 'none', fontWeight: 'bold' }}
>
Cancel
</Button>
<Button
onClick={handleViewHistory}
<Button
onClick={handleViewHistory}
variant="contained"
disabled={!selectedHistoryDate}
sx={{
bgcolor: '#3b82f6',
color: '#fff',
textTransform: 'none',
disabled={!historyFrom || !historyTill}
sx={{
bgcolor: '#3b82f6',
color: '#fff',
textTransform: 'none',
fontWeight: 'bold',
'&:hover': { bgcolor: '#2563eb' },
'&.Mui-disabled': { bgcolor: 'rgba(59, 130, 246, 0.3)', color: 'rgba(255,255,255,0.3)' }
@@ -339,47 +398,49 @@ export const TopFilterBar: React.FC = () => {
</Select>
</FormControl>
{/* Search ID Textfield */}
<TextField
size="small"
placeholder="Search ID..."
value={localSearchId}
onChange={(e) => handleSearchChange(e.target.value)}
slotProps={{
input: {
sx: {
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.1)',
color: 'white',
fontWeight: 'bold',
width: 150,
'& fieldset': { border: 'none' },
'& .MuiInputBase-input': { py: 1, px: 2, '&::placeholder': { color: 'rgba(255,255,255,0.5)', opacity: 1 } }
}
}
}}
/>
{/* Search Plaza Textfield */}
<TextField
size="small"
placeholder="Search Plaza..."
value={localPlaza}
onChange={(e) => handlePlazaChange(e.target.value)}
slotProps={{
input: {
sx: {
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.1)',
color: 'white',
fontWeight: 'bold',
width: 150,
'& fieldset': { border: 'none' },
'& .MuiInputBase-input': { py: 1, px: 2, '&::placeholder': { color: 'rgba(255,255,255,0.5)', opacity: 1 } }
}
}
}}
/>
{/* Search ID and Search Plaza — only visible when a specific site is selected */}
{localSiteId !== 'All Sites' && (
<>
<TextField
size="small"
placeholder="Search ID..."
value={localSearchId}
onChange={(e) => handleSearchChange(e.target.value)}
slotProps={{
input: {
sx: {
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.1)',
color: 'white',
fontWeight: 'bold',
width: 150,
'& fieldset': { border: 'none' },
'& .MuiInputBase-input': { py: 1, px: 2, '&::placeholder': { color: 'rgba(255,255,255,0.5)', opacity: 1 } }
}
}
}}
/>
<TextField
size="small"
placeholder="Search Plaza..."
value={localPlaza}
onChange={(e) => handlePlazaChange(e.target.value)}
slotProps={{
input: {
sx: {
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.1)',
color: 'white',
fontWeight: 'bold',
width: 150,
'& fieldset': { border: 'none' },
'& .MuiInputBase-input': { py: 1, px: 2, '&::placeholder': { color: 'rgba(255,255,255,0.5)', opacity: 1 } }
}
}
}}
/>
</>
)}
<Button
variant="contained"

View File

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

View File

@@ -102,7 +102,7 @@ export const MainLayout: React.FC = () => {
{/* Child routes get injected here */}
<Outlet />
</Box>
</Box>
);

14
src/constants/media.ts Normal file
View 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;

View File

@@ -1,58 +1,33 @@
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) =>
a.org_name.toLowerCase() > b.org_name.toLowerCase() ? 1 : -1
);
setOrganizations(sorted);
})
.catch((err) => {
console.error(err);
})
.finally(() => setLoading(false));
}
}, [open]);
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
);
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) => (
<ListItem disablePadding key={`${org.org_id || org.org_name}-${index}`}>
<ListItemButton onClick={() => onClose(org)}>
<ListItemText primary={org.org_name} />
</ListItemButton>
</ListItem>
))}
</List>
)}
<List>
{sorted.map((org, index) => (
<ListItem disablePadding key={`${org.org_id || org.org_name}-${index}`}>
<ListItemButton onClick={() => onClose(org)}>
<ListItemText primary={org.org_name} />
</ListItemButton>
</ListItem>
))}
</List>
</DialogContent>
</Dialog>
);

View File

@@ -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,8 +13,9 @@ 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);
if (!userData) {
const result = await accountService.login(username, password);
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>
);
};

View File

@@ -1,9 +1,28 @@
import React from 'react';
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Checkbox } from '@mui/material';
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Skeleton } from '@mui/material';
import { useFeedStore } from '../../store/feedStore';
// 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 } = useFeedStore();
const { tabs, selectedTabIndex, updateTab, filterPlaza, isRectificationEnabled, isFeedLoading } = useFeedStore();
if (tabs.length === 0 || selectedTabIndex < 0) {
return <Typography sx={{ p: 2 }}>Loading feeds...</Typography>;
@@ -59,12 +78,19 @@ 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>
{anomalies.map((anomaly: any, _filteredIndex: number) => {
{isFeedLoading && Array.from({ length: 8 }).map((_, i) => (
<TableRow key={`skel-${i}`}>
{Array.from({ length: SKELETON_COLS }).map((__, j) => (
<TableCell key={j} sx={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Skeleton variant="text" sx={{ bgcolor: 'rgba(255,255,255,0.06)', borderRadius: 1 }} />
</TableCell>
))}
</TableRow>
))}
{!isFeedLoading && anomalies.map((anomaly: any, _filteredIndex: number) => {
const realIndex = rawAnomalies.indexOf(anomaly);
const isSelected = activeTab.selectedAnomalyIndex === realIndex;
const assetName = anomaly?.Asset?.replace(/_/gi, ' ') || 'Unknown Asset';
@@ -107,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>
);
})}

View File

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

View File

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

View File

@@ -15,7 +15,9 @@ export const GlobalFeed: React.FC = () => {
isRectificationEnabled,
filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage,
selectedAssetIds
selectedAssetIds,
setIsFeedLoading,
sortBy, sortDir
} = useFeedStore();
useEffect(() => {
@@ -59,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) {
@@ -70,9 +72,11 @@ export const GlobalFeed: React.FC = () => {
const siteIdParam = (!selectedSite || selectedSite?.site_id === 'All Sites')
? '' : String(selectedSite.site_id || '');
setIsFeedLoading(true);
try {
const historyRes: any = await activityFeedsService.getHistory({
date: activeTab.date || '',
auditedByMe: activeTab.auditedByMe || false,
pageNo: 0,
pageSize: 20,
siteIds: siteIdParam,
@@ -81,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,
@@ -106,13 +112,15 @@ export const GlobalFeed: React.FC = () => {
}
} catch (err) {
console.error('[GlobalFeed] Failed to load history for active tab/filters change', err);
} finally {
setIsFeedLoading(false);
}
};
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) => {
@@ -125,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 || ''),
@@ -133,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,
@@ -165,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 || ''),

View File

@@ -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;
e.currentTarget.src = '/assets/images/master_image_not_available.png';
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;
e.currentTarget.src = '/assets/images/anomaly-placeholder.png';
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 }}>

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

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

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

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

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

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

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

View 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

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

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

View File

@@ -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,98 +112,116 @@ 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 */}
<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
}
{/* Start Button — unchanged behavior, driven by the filtered feed */}
<Button
variant="contained"
fullWidth
onClick={() => navigate('/audit-session', {
state: {
isReviewMode: sessionInfo.batchPending === 0,
startPage: sessionInfo.firstPendingIndex !== undefined && sessionInfo.firstPendingIndex !== -1 ? Math.floor(sessionInfo.firstPendingIndex / 50) : 0
}
})}
disabled={stats.batchTotal === 0}
sx={{
py: 2,
bgcolor: '#3b82f6',
disabled={sessionInfo.batchTotal === 0}
sx={{
py: 2,
bgcolor: '#3b82f6',
'&:hover': { bgcolor: '#2563eb' },
'&.Mui-disabled': { bgcolor: 'rgba(59, 130, 246, 0.3)', color: 'rgba(255,255,255,0.3)' },
textTransform: 'none',
@@ -194,11 +230,11 @@ export const Dashboard: React.FC = () => {
borderRadius: 2
}}
>
{stats.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.batchTotal === 0
? 'No Items to Audit'
: 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 */}

View File

@@ -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;
const userRoles = user.user_roles.split(',').map(r => r.trim().toLowerCase());
if (!user || !user.role) return false;
const neededRoles = requiredRole.split(',').map(r => r.trim().toLowerCase());
return neededRoles.some(role => userRoles.includes(role));
return neededRoles.includes(user.role.toLowerCase());
},
}),
{

View File

@@ -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;
@@ -58,6 +70,9 @@ interface FeedState {
dashboardCache: DashboardCache;
searchAnomalyId: string;
isRectificationEnabled: boolean;
isFeedLoading: boolean;
sortBy: FeedSortBy;
sortDir: FeedSortDir;
// Actions
setTabs: (tabs: FeedTab[]) => void;
@@ -82,6 +97,9 @@ interface FeedState {
setFilterPlaza: (val: string) => void;
setSearchAnomalyId: (id: string) => void;
setIsRectificationEnabled: (val: boolean) => void;
setIsFeedLoading: (val: boolean) => void;
setSortBy: (val: FeedSortBy) => void;
setSortDir: (val: FeedSortDir) => void;
resetStore: () => void;
}
@@ -120,6 +138,9 @@ const initialFeedState = {
dashboardCache: EMPTY_DASHBOARD_CACHE,
searchAnomalyId: '',
isRectificationEnabled: false,
isFeedLoading: false,
sortBy: '' as FeedSortBy,
sortDir: 'asc' as FeedSortDir,
};
export const useFeedStore = create<FeedState>()(
@@ -148,6 +169,9 @@ export const useFeedStore = create<FeedState>()(
setFilterPlaza: (filterPlaza) => set({ filterPlaza }),
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];
@@ -176,6 +200,8 @@ export const useFeedStore = create<FeedState>()(
auditStatus: state.auditStatus,
searchAnomalyId: state.searchAnomalyId,
isRectificationEnabled: state.isRectificationEnabled,
sortBy: state.sortBy,
sortDir: state.sortDir,
}),
}
)

30
src/store/toastStore.ts Normal file
View File

@@ -0,0 +1,30 @@
import { create } from 'zustand';
export type ToastSeverity = 'success' | 'error' | 'warning' | 'info';
interface Toast {
id: string;
message: string;
severity: ToastSeverity;
}
interface ToastState {
toasts: Toast[];
showToast: (message: string, severity?: ToastSeverity) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
showToast: (message, severity = 'info') => {
const id = `${Date.now()}-${Math.random()}`;
set((state) => ({ toasts: [...state.toasts, { id, message, severity }] }));
setTimeout(() => {
useToastStore.getState().removeToast(id);
}, 4000);
},
removeToast: (id) =>
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
}));

View File

@@ -6,7 +6,13 @@ export default defineConfig({
plugins: [react()],
server: {
allowedHosts: true,
// Reverse proxy is no longer needed since endpoints allow CORS natively
proxy: {
'/api/rocketchat': {
target: 'https://text.seekright.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/rocketchat/, ''),
},
},
},
preview: {
allowedHosts: true,