add remaining fleet operator functionality
This commit is contained in:
Generated
+135
-26
@@ -15,10 +15,12 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1",
|
||||||
|
"socket.io-client": "^4.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/google.maps": "^3.64.0",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -256,27 +259,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
|
||||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
|
||||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
@@ -797,6 +779,29 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.15",
|
"version": "1.0.0-rc.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
|
||||||
@@ -835,6 +840,12 @@
|
|||||||
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
|
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -920,6 +931,13 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/google.maps": {
|
||||||
|
"version": "3.64.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
|
||||||
|
"integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -939,6 +957,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -948,6 +967,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1008,6 +1028,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
|
||||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.58.2",
|
"@typescript-eslint/scope-manager": "8.58.2",
|
||||||
"@typescript-eslint/types": "8.58.2",
|
"@typescript-eslint/types": "8.58.2",
|
||||||
@@ -1275,6 +1296,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1375,6 +1397,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -1618,7 +1641,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
@@ -1657,6 +1679,28 @@
|
|||||||
"integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==",
|
"integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.18.3",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-toolkit": {
|
"node_modules/es-toolkit": {
|
||||||
"version": "1.45.1",
|
"version": "1.45.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
@@ -1688,6 +1732,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -2521,8 +2566,7 @@
|
|||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
@@ -2642,6 +2686,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2699,6 +2744,7 @@
|
|||||||
"version": "19.2.5",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -2707,6 +2753,7 @@
|
|||||||
"version": "19.2.5",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -2724,6 +2771,7 @@
|
|||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@@ -2807,7 +2855,8 @@
|
|||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -2910,6 +2959,34 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2998,6 +3075,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3108,6 +3186,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
||||||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@@ -3204,6 +3283,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@@ -3227,6 +3335,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-2
@@ -21,10 +21,18 @@ export const fleetApi = {
|
|||||||
return apiClient.get(url, { token });
|
return apiClient.get(url, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
searchStationsByName: async (token: string, stationName: string) => {
|
||||||
|
return apiClient.get(`/v1/fleet/stations?station_name=${encodeURIComponent(stationName)}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
getStationVehicles: async (token: string, stationId: string) => {
|
getStationVehicles: async (token: string, stationId: string) => {
|
||||||
return apiClient.get(`/v1/fleet/vehicles?station_id=${stationId}`, { token });
|
return apiClient.get(`/v1/fleet/vehicles?station_id=${stationId}`, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getVehiclesByStationName: async (token: string, stationName: string) => {
|
||||||
|
return apiClient.get(`/v1/fleet/vehicles?station_name=${encodeURIComponent(stationName)}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
createVehicle: async (vehicleData: any, token: string) => {
|
createVehicle: async (vehicleData: any, token: string) => {
|
||||||
return apiClient.post('/v1/fleet/vehicles', vehicleData, { token });
|
return apiClient.post('/v1/fleet/vehicles', vehicleData, { token });
|
||||||
},
|
},
|
||||||
@@ -34,6 +42,10 @@ export const fleetApi = {
|
|||||||
return apiClient.get(url, { token });
|
return apiClient.get(url, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getVehiclesByRegistrationNumber: async (token: string, registrationNumber: string) => {
|
||||||
|
return apiClient.get(`/v1/fleet/vehicles?registration_number=${encodeURIComponent(registrationNumber)}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
updateVehicleDetails: async (vehicleId: string, vehicleData: any, token: string) => {
|
updateVehicleDetails: async (vehicleId: string, vehicleData: any, token: string) => {
|
||||||
return apiClient.patch(`/v1/fleet/vehicles/${vehicleId}`, vehicleData, { token });
|
return apiClient.patch(`/v1/fleet/vehicles/${vehicleId}`, vehicleData, { token });
|
||||||
},
|
},
|
||||||
@@ -47,6 +59,23 @@ export const fleetApi = {
|
|||||||
return apiClient.get(url, { token });
|
return apiClient.get(url, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
searchStaff: async (token: string, params: { role?: string; name?: string }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.role) query.append('role', params.role);
|
||||||
|
if (params.name) query.append('name', params.name);
|
||||||
|
const qs = query.toString();
|
||||||
|
const url = qs ? `/v1/fleet/staff?${qs}` : `/v1/fleet/staff`;
|
||||||
|
return apiClient.get(url, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
getStaffByRole: async (token: string, role: string) => {
|
||||||
|
return apiClient.get(`/v1/fleet/staff?role=${encodeURIComponent(role)}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
getStaffByName: async (token: string, name: string) => {
|
||||||
|
return apiClient.get(`/v1/fleet/staff?name=${encodeURIComponent(name)}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
getRoster: async (token: string) => {
|
getRoster: async (token: string) => {
|
||||||
return apiClient.get('/v1/fleet/roster', { token });
|
return apiClient.get('/v1/fleet/roster', { token });
|
||||||
},
|
},
|
||||||
@@ -63,6 +92,15 @@ export const fleetApi = {
|
|||||||
return apiClient.get('/v1/fleet/inventory/master', { token });
|
return apiClient.get('/v1/fleet/inventory/master', { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
searchInventoryMaster: async (token: string, params: { name?: string; category?: string }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.name) query.append('name', params.name);
|
||||||
|
if (params.category) query.append('category', params.category);
|
||||||
|
const qs = query.toString();
|
||||||
|
const url = qs ? `/v1/fleet/inventory/master?${qs}` : `/v1/fleet/inventory/master`;
|
||||||
|
return apiClient.get(url, { token });
|
||||||
|
},
|
||||||
|
|
||||||
createInventoryMaster: async (payload: any[], token: string) => {
|
createInventoryMaster: async (payload: any[], token: string) => {
|
||||||
return apiClient.post('/v1/fleet/inventory/master', payload, { token });
|
return apiClient.post('/v1/fleet/inventory/master', payload, { token });
|
||||||
},
|
},
|
||||||
@@ -84,6 +122,15 @@ export const fleetApi = {
|
|||||||
return apiClient.get('/v1/fleet/inventory/warehouse', { token });
|
return apiClient.get('/v1/fleet/inventory/warehouse', { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
searchWarehouseStock: async (token: string, params: { name?: string; category?: string }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.name) query.append('name', params.name);
|
||||||
|
if (params.category) query.append('category', params.category);
|
||||||
|
const qs = query.toString();
|
||||||
|
const url = qs ? `/v1/fleet/inventory/warehouse?${qs}` : `/v1/fleet/inventory/warehouse`;
|
||||||
|
return apiClient.get(url, { token });
|
||||||
|
},
|
||||||
|
|
||||||
assignToVehicle: async (vehicleId: string, payload: any, token: string) => {
|
assignToVehicle: async (vehicleId: string, payload: any, token: string) => {
|
||||||
return apiClient.post(`/v1/fleet/vehicles/${vehicleId}/inventory/bulk`, payload, { token });
|
return apiClient.post(`/v1/fleet/vehicles/${vehicleId}/inventory/bulk`, payload, { token });
|
||||||
},
|
},
|
||||||
@@ -92,8 +139,12 @@ export const fleetApi = {
|
|||||||
return apiClient.get('/v1/fleet/inventory/restock-requests?status=PENDING', { token });
|
return apiClient.get('/v1/fleet/inventory/restock-requests?status=PENDING', { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
getRestockRequests: async (token: string, status?: string) => {
|
getRestockRequests: async (token: string, params?: { status?: string; search?: string }) => {
|
||||||
const url = status ? `/v1/fleet/inventory/restock-requests?status=${status}` : '/v1/fleet/inventory/restock-requests';
|
const query = new URLSearchParams();
|
||||||
|
if (params?.status) query.append('status', params.status);
|
||||||
|
if (params?.search) query.append('search', params.search);
|
||||||
|
const qs = query.toString();
|
||||||
|
const url = qs ? `/v1/fleet/inventory/restock-requests?${qs}` : `/v1/fleet/inventory/restock-requests`;
|
||||||
return apiClient.get(url, { token });
|
return apiClient.get(url, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface PaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalItems: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onItemsPerPageChange?: (itemsPerPage: number) => void;
|
||||||
|
itemsPerPageOptions?: number[];
|
||||||
|
itemsLabel?: string;
|
||||||
|
simpleMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Pagination: React.FC<PaginationProps> = ({
|
||||||
|
currentPage,
|
||||||
|
totalItems,
|
||||||
|
itemsPerPage,
|
||||||
|
onPageChange,
|
||||||
|
onItemsPerPageChange,
|
||||||
|
itemsPerPageOptions = [5, 10, 20, 50],
|
||||||
|
itemsLabel = 'items',
|
||||||
|
simpleMode = false
|
||||||
|
}) => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
||||||
|
const safePage = Math.min(currentPage, totalPages);
|
||||||
|
|
||||||
|
if (simpleMode) {
|
||||||
|
const indexOfFirstItem = (safePage - 1) * itemsPerPage;
|
||||||
|
const indexOfLastItem = safePage * itemsPerPage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', borderTop: '1px solid rgba(15, 23, 42, 0.08)', marginTop: '8px' }}>
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: '#64748B' }}>
|
||||||
|
Showing <strong style={{ color: '#0F172A' }}>{totalItems === 0 ? 0 : indexOfFirstItem + 1}</strong> to <strong style={{ color: '#0F172A' }}>{Math.min(indexOfLastItem, totalItems)}</strong> of <strong style={{ color: '#0F172A' }}>{totalItems}</strong> {itemsLabel}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(Math.max(1, safePage - 1))}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', background: 'transparent', border: '1px solid #CBD5E1', borderRadius: '6px',
|
||||||
|
color: safePage === 1 ? '#94A3B8' : '#475569', cursor: safePage === 1 ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> Prev
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 8px', fontSize: '0.8125rem', fontWeight: 700, color: '#0F172A' }}>
|
||||||
|
{safePage} / {totalPages}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
|
||||||
|
disabled={safePage === totalPages}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', background: 'transparent', border: '1px solid #CBD5E1', borderRadius: '6px',
|
||||||
|
color: safePage === totalPages ? '#94A3B8' : '#475569', cursor: safePage === totalPages ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 20px', borderTop: '1px solid rgba(15, 23, 42, 0.08)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: '#475569' }}>
|
||||||
|
Showing <strong style={{ color: '#0F172A' }}>{totalItems === 0 ? 0 : (safePage - 1) * itemsPerPage + 1}–{Math.min(safePage * itemsPerPage, totalItems)}</strong> of <strong style={{ color: '#0F172A' }}>{totalItems}</strong> {itemsLabel}
|
||||||
|
</span>
|
||||||
|
{onItemsPerPageChange && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>Rows per page:</span>
|
||||||
|
<select
|
||||||
|
value={itemsPerPage}
|
||||||
|
onChange={e => {
|
||||||
|
onItemsPerPageChange(Number(e.target.value));
|
||||||
|
onPageChange(1);
|
||||||
|
}}
|
||||||
|
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #CBD5E1', background: '#FFFFFF', color: '#0F172A', fontSize: '0.75rem', outline: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{itemsPerPageOptions.map(option => (
|
||||||
|
<option key={option} value={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === 1 ? '#94A3B8' : '#475569', cursor: safePage === 1 ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<ChevronsLeft size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(Math.max(1, safePage - 1))}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === 1 ? '#94A3B8' : '#475569', cursor: safePage === 1 ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter(p => p === 1 || p === totalPages || Math.abs(p - safePage) <= 1)
|
||||||
|
.map((p, i, arr) => (
|
||||||
|
<React.Fragment key={p}>
|
||||||
|
{i > 0 && arr[i - 1] !== p - 1 && <span style={{ padding: '0 4px', color: '#94A3B8' }}>...</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: 'none', background: p === safePage ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : '#F1F5F9', color: p === safePage ? '#fff' : '#475569', fontWeight: p === safePage ? 700 : 500, cursor: 'pointer', fontSize: '0.82rem' }}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
|
||||||
|
disabled={safePage === totalPages}
|
||||||
|
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === totalPages ? '#94A3B8' : '#475569', cursor: safePage === totalPages ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
disabled={safePage === totalPages}
|
||||||
|
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === totalPages ? '#94A3B8' : '#475569', cursor: safePage === totalPages ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<ChevronsRight size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -98,7 +98,7 @@ export const FleetLogin = () => {
|
|||||||
localStorage.setItem('teleems_user', JSON.stringify({ ...fallback, roles: ['Fleet Operator'] }));
|
localStorage.setItem('teleems_user', JSON.stringify({ ...fallback, roles: ['Fleet Operator'] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate('/fleet-operator?tab=organization');
|
navigate('/fleet-operator?tab=overview');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setShowError(loginJson?.message || 'Access Denied: Invalid Credentials');
|
setShowError(loginJson?.message || 'Access Denied: Invalid Credentials');
|
||||||
@@ -128,7 +128,7 @@ export const FleetLogin = () => {
|
|||||||
|
|
||||||
const userToStore = { ...baseUser, roles, mfa_enabled: true };
|
const userToStore = { ...baseUser, roles, mfa_enabled: true };
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
||||||
navigate('/fleet-operator?tab=organization');
|
navigate('/fleet-operator?tab=overview');
|
||||||
} else {
|
} else {
|
||||||
setShowError('Invalid Security Token');
|
setShowError('Invalid Security Token');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,14 +120,7 @@ export const FleetOperatorDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
<div className="fleet-search-container" style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'rgba(255,255,255,0.03)', padding: '8px 16px', borderRadius: '20px', border: '1px solid rgba(255,255,255,0.06)' }}>
|
|
||||||
<Search size={14} color="#64748B" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search fleet..."
|
|
||||||
style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.82rem', width: '140px', outline: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button className="fleet-header-btn" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '8px', color: '#94A3B8', cursor: 'pointer', position: 'relative', display: 'flex' }}>
|
<button className="fleet-header-btn" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '8px', color: '#94A3B8', cursor: 'pointer', position: 'relative', display: 'flex' }}>
|
||||||
<Bell size={18} />
|
<Bell size={18} />
|
||||||
<span style={{ position: 'absolute', top: 6, right: 6, width: '7px', height: '7px', background: '#EF4444', borderRadius: '50%', border: '1px solid #040B16' }} />
|
<span style={{ position: 'absolute', top: 6, right: 6, width: '7px', height: '7px', background: '#EF4444', borderRadius: '50%', border: '1px solid #040B16' }} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Clock, CheckCircle, Car, User, Activity, Loader2, Calendar } from 'lucide-react';
|
import { Clock, CheckCircle, Car, User, Activity, Loader2, Calendar, ChevronLeft } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface ActiveShift {
|
interface ActiveShift {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,6 +29,7 @@ interface ActiveShift {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FleetActiveShifts: React.FC = () => {
|
export const FleetActiveShifts: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [shifts, setShifts] = useState<ActiveShift[]>([]);
|
const [shifts, setShifts] = useState<ActiveShift[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -61,99 +63,137 @@ export const FleetActiveShifts: React.FC = () => {
|
|||||||
}, [fetchActiveShifts]);
|
}, [fetchActiveShifts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ color: '#F8FAFC', fontFamily: 'Inter, sans-serif' }}>
|
<div className="animate-in fade-in duration-500" style={{ color: '#0F172A', fontFamily: 'Inter, sans-serif' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
||||||
|
background: '#FFFFFF', border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '12px', color: '#475569', fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer',
|
||||||
|
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.05)', transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = '#FFFFFF'}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} /> Back to Dashboard
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={fetchActiveShifts}
|
onClick={fetchActiveShifts}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 12px',
|
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
||||||
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)',
|
background: '#FFFFFF', border: '1px solid #E2E8F0',
|
||||||
borderRadius: 10, color: '#94A3B8', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer'
|
borderRadius: '12px', color: '#06B6D4', fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer',
|
||||||
|
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.05)', transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = '#FFFFFF'}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 size={13} className="spin" /> : '↺ Refresh'}
|
{loading ? <Loader2 size={14} className="spin" /> : '↺ Refresh Status'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', padding: '12px 16px', borderRadius: 12, color: '#EF4444', fontSize: '0.8rem', marginBottom: 20 }}>
|
<div style={{ background: '#FEF2F2', border: '1px solid #FCA5A5', padding: '16px 20px', borderRadius: '12px', color: '#EF4444', fontSize: '0.85rem', fontWeight: 600, marginBottom: 24, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{error}
|
<Activity size={16} /> {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', minHeight: 300, gap: '16px' }}>
|
||||||
<Loader2 size={32} color="#06B6D4" className="spin" />
|
<Loader2 size={36} color="#06B6D4" className="spin" />
|
||||||
|
<span style={{ color: '#64748B', fontWeight: 600, fontSize: '0.9rem' }}>Fetching active shift telemetries...</span>
|
||||||
</div>
|
</div>
|
||||||
) : shifts.length === 0 ? (
|
) : shifts.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '64px 24px', background: 'rgba(255,255,255,0.01)', border: '1px dashed rgba(255,255,255,0.08)', borderRadius: 16 }}>
|
<div style={{ textAlign: 'center', padding: '80px 24px', background: '#FFFFFF', border: '1px dashed #CBD5E1', borderRadius: '16px', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.02)' }}>
|
||||||
<CheckCircle size={36} color="#10B981" style={{ marginBottom: 12 }} />
|
<CheckCircle size={48} color="#10B981" style={{ marginBottom: 16, opacity: 0.8 }} />
|
||||||
<p style={{ color: '#64748B', fontSize: '0.88rem', margin: 0 }}>No active shifts at the moment. All shifts have been completed or none are currently scheduled.</p>
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 800, color: '#0F172A', margin: '0 0 8px' }}>All Clear</h3>
|
||||||
|
<p style={{ color: '#64748B', fontSize: '0.9rem', margin: 0 }}>No active shifts at the moment. All shifts have been completed or none are currently scheduled.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '20px' }}>
|
||||||
{shifts.map((shift, i) => (
|
{shifts.map((shift, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={shift.id}
|
key={shift.id}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: i * 0.05 }}
|
transition={{ delay: i * 0.05, duration: 0.3 }}
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(255,255,255,0.02)',
|
background: '#FFFFFF',
|
||||||
border: '1px solid rgba(255,255,255,0.07)',
|
border: '1px solid #E2E8F0',
|
||||||
borderRadius: 16,
|
borderRadius: '20px',
|
||||||
padding: '18px 24px',
|
padding: '24px',
|
||||||
|
boxShadow: '0 4px 12px -2px rgba(15, 23, 42, 0.05)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '20px',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
}}
|
}}
|
||||||
|
whileHover={{ y: -4, boxShadow: '0 12px 20px -4px rgba(15, 23, 42, 0.08)', borderColor: '#CBD5E1' }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, flexWrap: 'wrap', gap: 12 }}>
|
{/* Top Accent Line */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '4px', background: 'linear-gradient(90deg, #06B6D4, #3B82F6)' }} />
|
||||||
{/* Vehicle identifier */}
|
|
||||||
<div style={{ padding: '6px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 9, display: 'flex', alignItems: 'center', gap: 7 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<Car size={14} color="#06B6D4" />
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||||
<span style={{ fontWeight: 800, fontSize: '0.82rem', color: '#000000' }}>
|
<div style={{ width: '48px', height: '48px', borderRadius: '14px', background: 'rgba(6, 182, 212, 0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Car size={24} color="#06B6D4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: '1.1rem', color: '#0F172A', marginBottom: '2px' }}>
|
||||||
{shift.vehicle?.registration_number || 'Unknown Vehicle'}
|
{shift.vehicle?.registration_number || 'Unknown Vehicle'}
|
||||||
</span>
|
</div>
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#000000', background: 'rgba(6,182,212,0.12)', padding: '1px 7px', borderRadius: 4 }}>
|
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: '#06B6D4', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||||
{shift.vehicle?.vehicle_type || 'N/A'}
|
{shift.vehicle?.vehicle_type || 'N/A Type'}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Badge */}
|
|
||||||
<div style={{ padding: '5px 10px', background: 'rgba(16,185,129,0.1)', border: `1px solid rgba(16,185,129,0.3)`, borderRadius: 7, display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<Clock size={11} color="#10B981" />
|
|
||||||
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#10B981' }}>{shift.status}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time details */}
|
|
||||||
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: '#64748B', display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<Calendar size={12} /> Started: {new Date(shift.startTime).toLocaleString()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div style={{ padding: '6px 12px', background: '#DCFCE7', border: '1px solid #BBF7D0', borderRadius: '20px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<Clock size={14} color="#15803D" />
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: '#15803D', letterSpacing: '0.5px' }}>{shift.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time details */}
|
||||||
|
<div style={{ padding: '12px 16px', background: '#F8FAFC', borderRadius: '12px', border: '1px dashed #CBD5E1' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', marginBottom: '4px', letterSpacing: '0.5px' }}>Shift Started At</div>
|
||||||
|
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#334155', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Calendar size={14} color="#64748B" /> {new Date(shift.startTime).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Crew Members */}
|
{/* Crew Members */}
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 800, color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px', marginLeft: '4px' }}>Assigned Crew</div>
|
||||||
|
|
||||||
{/* Driver */}
|
{/* Driver */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.08)', borderRadius: 12, flex: 1, minWidth: 200 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', padding: '14px', background: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '14px' }}>
|
||||||
<User size={15} color="#06B6D4" />
|
<div style={{ width: '36px', height: '36px', borderRadius: '50%', background: '#F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<User size={18} color="#64748B" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>Pilot Driver</div>
|
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#0F172A' }}>
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#000000' }}>
|
|
||||||
{shift.driver?.user?.name || 'Driver Profile'}
|
{shift.driver?.user?.name || 'Driver Profile'}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748B', fontWeight: 600 }}>Pilot / Driver</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Staff / EMT */}
|
{/* Staff / EMT */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'rgba(59,130,246,0.04)', border: '1px solid rgba(59,130,246,0.08)', borderRadius: 12, flex: 1, minWidth: 200 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', padding: '14px', background: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '14px' }}>
|
||||||
<Activity size={15} color="#3B82F6" />
|
<div style={{ width: '36px', height: '36px', borderRadius: '50%', background: '#EFF6FF', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Activity size={18} color="#3B82F6" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>
|
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#0F172A' }}>
|
||||||
{shift.staff?.type === 'DOCTOR' ? 'Medical Doctor' : 'Emergency Medical Technician'}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#000000' }}>
|
|
||||||
{shift.staff?.user?.name || 'EMT/Staff Profile'}
|
{shift.staff?.user?.name || 'EMT/Staff Profile'}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#3B82F6', fontWeight: 600 }}>
|
||||||
|
{shift.staff?.type === 'DOCTOR' ? 'Medical Doctor' : 'Emergency Medical Tech'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { fleetApi } from '../../api/fleet';
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
import { Pagination } from '../../components/Pagination';
|
||||||
|
|
||||||
interface APIVehicle {
|
interface APIVehicle {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,6 +54,8 @@ export const FleetAnalytics: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [timeRange, setTimeRange] = useState<'7D' | '30D' | 'ALL'>('ALL');
|
const [timeRange, setTimeRange] = useState<'7D' | '30D' | 'ALL'>('ALL');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(5);
|
||||||
|
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
@@ -220,19 +223,18 @@ export const FleetAnalytics: React.FC = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(vehicles.length / itemsPerPage);
|
||||||
|
const safePage = Math.min(currentPage, Math.max(1, totalPages));
|
||||||
|
const indexOfLastVehicle = safePage * itemsPerPage;
|
||||||
|
const indexOfFirstVehicle = indexOfLastVehicle - itemsPerPage;
|
||||||
|
const currentVehicles = vehicles.slice(indexOfFirstVehicle, indexOfLastVehicle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 28, color: '#F8FAFC' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 28, color: '#F8FAFC' }}>
|
||||||
|
|
||||||
{/* HEADER CONTROLS */}
|
{/* HEADER CONTROLS */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||||
<div>
|
<div></div>
|
||||||
<h2 style={{ fontSize: '1.4rem', fontWeight: 900, color: '#fff', margin: 0, letterSpacing: '-0.5px' }}>
|
|
||||||
Fleet Telemetry & Resource Intelligence
|
|
||||||
</h2>
|
|
||||||
<p style={{ margin: '4px 0 0', fontSize: '0.78rem', color: '#64748B' }}>
|
|
||||||
Real-time analytics harvested from live active-shifts, scheduling rosters and vehicle metadata.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ display: 'flex', gap: 4, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 10, padding: 3 }}>
|
<div style={{ display: 'flex', gap: 4, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 10, padding: 3 }}>
|
||||||
@@ -542,7 +544,7 @@ export const FleetAnalytics: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{vehicles.map((v) => {
|
{currentVehicles.map((v) => {
|
||||||
// Extract active driver/staff from shift or roster
|
// Extract active driver/staff from shift or roster
|
||||||
const driverName = v.activeShift?.driver?.user?.name || v.activeRoster?.driver?.user?.name || '—';
|
const driverName = v.activeShift?.driver?.user?.name || v.activeRoster?.driver?.user?.name || '—';
|
||||||
const driverPhone = v.activeShift?.driver?.user?.phone || v.activeRoster?.driver?.user?.phone || '';
|
const driverPhone = v.activeShift?.driver?.user?.phone || v.activeRoster?.driver?.user?.phone || '';
|
||||||
@@ -604,6 +606,16 @@ export const FleetAnalytics: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{vehicles.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={safePage}
|
||||||
|
totalItems={vehicles.length}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onItemsPerPageChange={setItemsPerPage}
|
||||||
|
itemsLabel="vehicles"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+168
-80
@@ -4,6 +4,7 @@ import {
|
|||||||
AlertCircle, ChevronDown, Edit2
|
AlertCircle, ChevronDown, Edit2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
|
||||||
interface Station {
|
interface Station {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +18,10 @@ interface Vehicle {
|
|||||||
brand?: string;
|
brand?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
chassis_number?: string;
|
chassis_number?: string;
|
||||||
|
engine_number?: string;
|
||||||
|
make_year?: number;
|
||||||
|
stretcher_capacity?: number;
|
||||||
|
ownership_type?: string;
|
||||||
station_id?: string;
|
station_id?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
@@ -28,9 +33,14 @@ interface VehicleForm {
|
|||||||
brand: string;
|
brand: string;
|
||||||
model: string;
|
model: string;
|
||||||
station_id: string;
|
station_id: string;
|
||||||
|
engine_number: string;
|
||||||
|
make_year: number | '';
|
||||||
|
stretcher_capacity: number | '';
|
||||||
|
ownership_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VEHICLE_TYPES = ['ALS', 'BLS', 'TRANSPORT', 'ICU', 'NEONATAL'];
|
const VEHICLE_TYPES = ['ALS', 'BLS', 'TRANSPORT', 'ICU', 'NEONATAL'];
|
||||||
|
const OWNERSHIP_TYPES = ['self-owned', 'hospital-owned', 'leased'];
|
||||||
|
|
||||||
const EMPTY_FORM: VehicleForm = {
|
const EMPTY_FORM: VehicleForm = {
|
||||||
registration_number: '',
|
registration_number: '',
|
||||||
@@ -39,6 +49,10 @@ const EMPTY_FORM: VehicleForm = {
|
|||||||
brand: '',
|
brand: '',
|
||||||
model: '',
|
model: '',
|
||||||
station_id: '',
|
station_id: '',
|
||||||
|
engine_number: '',
|
||||||
|
make_year: '',
|
||||||
|
stretcher_capacity: '',
|
||||||
|
ownership_type: 'self-owned',
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
@@ -104,10 +118,7 @@ export const FleetAssets: React.FC = () => {
|
|||||||
const fetchStations = useCallback(async () => {
|
const fetchStations = useCallback(async () => {
|
||||||
setStationsLoading(true);
|
setStationsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/stations', {
|
const json = await fleetApi.getStations(token);
|
||||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
let list: Station[] = [];
|
let list: Station[] = [];
|
||||||
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||||
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||||
@@ -123,14 +134,10 @@ export const FleetAssets: React.FC = () => {
|
|||||||
const fetchVehicles = useCallback(async () => {
|
const fetchVehicles = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const url = selectedStationFilter
|
const json = selectedStationFilter
|
||||||
? `https://teleems-api-gateway.onrender.com/v1/fleet/vehicles?station_id=${selectedStationFilter}`
|
? await fleetApi.getVehiclesByStationName(token, selectedStationFilter)
|
||||||
: 'https://teleems-api-gateway.onrender.com/v1/fleet/vehicles';
|
: await fleetApi.getVehicles(token);
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
let list: Vehicle[] = [];
|
let list: Vehicle[] = [];
|
||||||
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||||
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||||
@@ -145,6 +152,27 @@ export const FleetAssets: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { fetchVehicles(); fetchStations(); }, [fetchVehicles, fetchStations]);
|
useEffect(() => { fetchVehicles(); fetchStations(); }, [fetchVehicles, fetchStations]);
|
||||||
|
|
||||||
|
// Debounced search by registration_number — fires 400ms after user stops typing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) return; // empty → fetchVehicles handles it via selectedStationFilter
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const json = await fleetApi.getVehiclesByRegistrationNumber(token, searchQuery.trim());
|
||||||
|
let list: Vehicle[] = [];
|
||||||
|
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||||
|
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||||
|
else if (Array.isArray(json)) list = json;
|
||||||
|
setVehicles(list);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to search vehicles:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, token]);
|
||||||
|
|
||||||
const openModal = () => {
|
const openModal = () => {
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -159,6 +187,10 @@ export const FleetAssets: React.FC = () => {
|
|||||||
brand: v.brand || '',
|
brand: v.brand || '',
|
||||||
model: v.model || '',
|
model: v.model || '',
|
||||||
station_id: v.station_id || '',
|
station_id: v.station_id || '',
|
||||||
|
engine_number: v.engine_number || '',
|
||||||
|
make_year: v.make_year || '',
|
||||||
|
stretcher_capacity: v.stretcher_capacity || '',
|
||||||
|
ownership_type: v.ownership_type || 'self-owned',
|
||||||
});
|
});
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
@@ -175,23 +207,20 @@ export const FleetAssets: React.FC = () => {
|
|||||||
chassis_number: editForm.chassis_number || undefined,
|
chassis_number: editForm.chassis_number || undefined,
|
||||||
brand: editForm.brand,
|
brand: editForm.brand,
|
||||||
model: editForm.model,
|
model: editForm.model,
|
||||||
|
engine_number: editForm.engine_number || undefined,
|
||||||
|
make_year: editForm.make_year ? Number(editForm.make_year) : undefined,
|
||||||
|
stretcher_capacity: editForm.stretcher_capacity ? Number(editForm.stretcher_capacity) : undefined,
|
||||||
|
ownership_type: editForm.ownership_type || undefined,
|
||||||
};
|
};
|
||||||
const res = await fetch(
|
const json = await fleetApi.updateVehicleDetails(editingVehicle.id, payload, token);
|
||||||
`https://teleems-api-gateway.onrender.com/v1/fleet/vehicles/${editingVehicle.id}`,
|
if (!json) return;
|
||||||
{
|
if (!json.status || json.status < 400 || json?.id || json?.data?.id) {
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const json = await res.json();
|
|
||||||
if (res.ok || json?.id || json?.data?.id) {
|
|
||||||
showToast('success', 'Vehicle updated successfully!');
|
showToast('success', 'Vehicle updated successfully!');
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingVehicle(null);
|
setEditingVehicle(null);
|
||||||
fetchVehicles();
|
fetchVehicles();
|
||||||
} else {
|
} else {
|
||||||
showToast('error', json?.message || `Error ${res.status}: Failed to update vehicle.`);
|
showToast('error', json?.message || `Error ${json.status || ''}: Failed to update vehicle.`);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast('error', err?.message || 'Network error.');
|
showToast('error', err?.message || 'Network error.');
|
||||||
@@ -215,25 +244,21 @@ export const FleetAssets: React.FC = () => {
|
|||||||
chassis_number: form.chassis_number || undefined,
|
chassis_number: form.chassis_number || undefined,
|
||||||
brand: form.brand,
|
brand: form.brand,
|
||||||
model: form.model,
|
model: form.model,
|
||||||
|
engine_number: form.engine_number || undefined,
|
||||||
|
make_year: form.make_year ? Number(form.make_year) : undefined,
|
||||||
|
stretcher_capacity: form.stretcher_capacity ? Number(form.stretcher_capacity) : undefined,
|
||||||
|
ownership_type: form.ownership_type || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/vehicles', {
|
const json = await fleetApi.createVehicle(payload, token);
|
||||||
method: 'POST',
|
if (!json) return;
|
||||||
headers: {
|
if (!json.status || json.status < 400 || json?.id || json?.data?.id) {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
if (res.ok || res.status === 201 || json?.id || json?.data?.id) {
|
|
||||||
showToast('success', `Vehicle "${form.registration_number}" registered successfully!`);
|
showToast('success', `Vehicle "${form.registration_number}" registered successfully!`);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
fetchVehicles();
|
fetchVehicles();
|
||||||
} else {
|
} else {
|
||||||
showToast('error', json?.message || `Error ${res.status}: Failed to register vehicle.`);
|
showToast('error', json?.message || `Error ${json.status || ''}: Failed to register vehicle.`);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast('error', err?.message || 'Network error. Please check your connection.');
|
showToast('error', err?.message || 'Network error. Please check your connection.');
|
||||||
@@ -242,11 +267,12 @@ export const FleetAssets: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filtered = vehicles.filter(v =>
|
// When search is cleared, reload via fetchVehicles (respects station filter)
|
||||||
v.registration_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
useEffect(() => {
|
||||||
v.brand?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
if (!searchQuery.trim()) {
|
||||||
v.model?.toLowerCase().includes(searchQuery.toLowerCase())
|
fetchVehicles();
|
||||||
);
|
}
|
||||||
|
}, [searchQuery, fetchVehicles]);
|
||||||
|
|
||||||
const getStatusColor = (status?: string) => {
|
const getStatusColor = (status?: string) => {
|
||||||
if (status === 'ACTIVE') return { bg: 'rgba(34,197,94,0.1)', border: 'rgba(34,197,94,0.2)', color: '#22C55E' };
|
if (status === 'ACTIVE') return { bg: 'rgba(34,197,94,0.1)', border: 'rgba(34,197,94,0.2)', color: '#22C55E' };
|
||||||
@@ -302,22 +328,35 @@ export const FleetAssets: React.FC = () => {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
|
||||||
<div>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
<label style={labelStyle}>Registration Number *</label>
|
<div>
|
||||||
<input style={inputStyle} required value={form.registration_number} onChange={e => setForm(f => ({ ...f, registration_number: e.target.value }))} />
|
<label style={labelStyle}>Registration Number *</label>
|
||||||
|
<input style={inputStyle} required value={form.registration_number} onChange={e => setForm(f => ({ ...f, registration_number: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<label style={labelStyle}>Assign to Station *</label>
|
||||||
|
<select style={selectStyle} required value={form.station_id} onChange={e => setForm(f => ({ ...f, station_id: e.target.value }))}>
|
||||||
|
<option value="">— Select a Station —</option>
|
||||||
|
{stations.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={16} color="#64748B" style={{ position: 'absolute', right: '14px', bottom: '13px', pointerEvents: 'none' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
<label style={labelStyle}>Vehicle Type *</label>
|
<div style={{ position: 'relative' }}>
|
||||||
<select style={selectStyle} required value={form.vehicle_type} onChange={e => setForm(f => ({ ...f, vehicle_type: e.target.value }))}>
|
<label style={labelStyle}>Vehicle Type *</label>
|
||||||
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
<select style={selectStyle} required value={form.vehicle_type} onChange={e => setForm(f => ({ ...f, vehicle_type: e.target.value }))}>
|
||||||
</select>
|
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</div>
|
</select>
|
||||||
<div style={{ position: 'relative' }}>
|
<ChevronDown size={16} color="#64748B" style={{ position: 'absolute', right: '14px', bottom: '13px', pointerEvents: 'none' }} />
|
||||||
<label style={labelStyle}>Assign to Station *</label>
|
</div>
|
||||||
<select style={selectStyle} required value={form.station_id} onChange={e => setForm(f => ({ ...f, station_id: e.target.value }))}>
|
<div style={{ position: 'relative' }}>
|
||||||
<option value="">— Select a Station —</option>
|
<label style={labelStyle}>Ownership Type</label>
|
||||||
{stations.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
<select style={selectStyle} value={form.ownership_type} onChange={e => setForm(f => ({ ...f, ownership_type: e.target.value }))}>
|
||||||
</select>
|
{OWNERSHIP_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={16} color="#64748B" style={{ position: 'absolute', right: '14px', bottom: '13px', pointerEvents: 'none' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -329,9 +368,25 @@ export const FleetAssets: React.FC = () => {
|
|||||||
<input style={inputStyle} required value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
|
<input style={inputStyle} required value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
<label style={labelStyle}>Chassis Number</label>
|
<div>
|
||||||
<input style={{ ...inputStyle, fontFamily: 'monospace' }} value={form.chassis_number} onChange={e => setForm(f => ({ ...f, chassis_number: e.target.value }))} />
|
<label style={labelStyle}>Make Year</label>
|
||||||
|
<input type="number" style={inputStyle} value={form.make_year} onChange={e => setForm(f => ({ ...f, make_year: e.target.value ? Number(e.target.value) : '' }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Patient Capacity</label>
|
||||||
|
<input type="number" style={inputStyle} value={form.stretcher_capacity} onChange={e => setForm(f => ({ ...f, stretcher_capacity: e.target.value ? Number(e.target.value) : '' }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Chassis Number</label>
|
||||||
|
<input style={{ ...inputStyle, fontFamily: 'monospace' }} value={form.chassis_number} onChange={e => setForm(f => ({ ...f, chassis_number: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Engine Number</label>
|
||||||
|
<input style={{ ...inputStyle, fontFamily: 'monospace' }} value={form.engine_number} onChange={e => setForm(f => ({ ...f, engine_number: e.target.value }))} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
||||||
@@ -367,22 +422,35 @@ export const FleetAssets: React.FC = () => {
|
|||||||
|
|
||||||
<form onSubmit={handleUpdate}>
|
<form onSubmit={handleUpdate}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
|
||||||
<div>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
<label style={labelStyle}>Registration Number *</label>
|
<div>
|
||||||
<input style={inputStyle} required value={editForm.registration_number} onChange={e => setEditForm(f => ({ ...f, registration_number: e.target.value }))} />
|
<label style={labelStyle}>Registration Number *</label>
|
||||||
|
<input style={inputStyle} required value={editForm.registration_number} onChange={e => setEditForm(f => ({ ...f, registration_number: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<label style={labelStyle}>Assign to Station</label>
|
||||||
|
<select style={selectStyle} value={editForm.station_id} onChange={e => setEditForm(f => ({ ...f, station_id: e.target.value }))}>
|
||||||
|
<option value="">— Select a Station —</option>
|
||||||
|
{stations.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={16} color="#64748B" style={{ position: 'absolute', right: '14px', bottom: '13px', pointerEvents: 'none' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
<label style={labelStyle}>Vehicle Type *</label>
|
<div style={{ position: 'relative' }}>
|
||||||
<select style={selectStyle} required value={editForm.vehicle_type} onChange={e => setEditForm(f => ({ ...f, vehicle_type: e.target.value }))}>
|
<label style={labelStyle}>Vehicle Type *</label>
|
||||||
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
<select style={selectStyle} required value={editForm.vehicle_type} onChange={e => setEditForm(f => ({ ...f, vehicle_type: e.target.value }))}>
|
||||||
</select>
|
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</div>
|
</select>
|
||||||
<div style={{ position: 'relative' }}>
|
<ChevronDown size={16} color="#64748B" style={{ position: 'absolute', right: '14px', bottom: '13px', pointerEvents: 'none' }} />
|
||||||
<label style={labelStyle}>Assign to Station</label>
|
</div>
|
||||||
<select style={selectStyle} value={editForm.station_id} onChange={e => setEditForm(f => ({ ...f, station_id: e.target.value }))}>
|
<div style={{ position: 'relative' }}>
|
||||||
<option value="">— Select a Station —</option>
|
<label style={labelStyle}>Ownership Type</label>
|
||||||
{stations.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
<select style={selectStyle} value={editForm.ownership_type} onChange={e => setEditForm(f => ({ ...f, ownership_type: e.target.value }))}>
|
||||||
</select>
|
{OWNERSHIP_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={16} color="#64748B" style={{ position: 'absolute', right: '14px', bottom: '13px', pointerEvents: 'none' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -394,9 +462,25 @@ export const FleetAssets: React.FC = () => {
|
|||||||
<input style={inputStyle} required value={editForm.model} onChange={e => setEditForm(f => ({ ...f, model: e.target.value }))} />
|
<input style={inputStyle} required value={editForm.model} onChange={e => setEditForm(f => ({ ...f, model: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
<label style={labelStyle}>Chassis Number</label>
|
<div>
|
||||||
<input style={{ ...inputStyle, fontFamily: 'monospace' }} value={editForm.chassis_number} onChange={e => setEditForm(f => ({ ...f, chassis_number: e.target.value }))} />
|
<label style={labelStyle}>Make Year</label>
|
||||||
|
<input type="number" style={inputStyle} value={editForm.make_year} onChange={e => setEditForm(f => ({ ...f, make_year: e.target.value ? Number(e.target.value) : '' }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Patient Capacity</label>
|
||||||
|
<input type="number" style={inputStyle} value={editForm.stretcher_capacity} onChange={e => setEditForm(f => ({ ...f, stretcher_capacity: e.target.value ? Number(e.target.value) : '' }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Chassis Number</label>
|
||||||
|
<input style={{ ...inputStyle, fontFamily: 'monospace' }} value={editForm.chassis_number} onChange={e => setEditForm(f => ({ ...f, chassis_number: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Engine Number</label>
|
||||||
|
<input style={{ ...inputStyle, fontFamily: 'monospace' }} value={editForm.engine_number} onChange={e => setEditForm(f => ({ ...f, engine_number: e.target.value }))} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
||||||
@@ -488,7 +572,7 @@ export const FleetAssets: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<option value="">📁 All Stations</option>
|
<option value="">📁 All Stations</option>
|
||||||
{stations.map(s => (
|
{stations.map(s => (
|
||||||
<option key={s.id} value={s.id}>📍 {s.name}</option>
|
<option key={s.id} value={s.name}>📍 {s.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -519,16 +603,16 @@ export const FleetAssets: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && vehicles.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#64748B' }}>
|
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#64748B' }}>
|
||||||
<Truck size={48} style={{ opacity: 0.2, marginBottom: '16px' }} />
|
<Truck size={48} style={{ opacity: 0.2, marginBottom: '16px' }} />
|
||||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#475569', marginBottom: '8px' }}>No Vehicles Found</h3>
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#475569', marginBottom: '8px' }}>No Vehicles Found</h3>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && vehicles.length > 0 && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
|
||||||
{filtered.map(v => {
|
{vehicles.map(v => {
|
||||||
const sc = getStatusColor(v.status);
|
const sc = getStatusColor(v.status);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -540,10 +624,14 @@ export const FleetAssets: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#64748B', marginBottom: '4px' }}>{v.vehicle_type} UNIT</div>
|
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#64748B', marginBottom: '4px' }}>{v.vehicle_type} UNIT</div>
|
||||||
<h3 style={{ fontSize: '1rem', fontWeight: 800, color: '#0F172A', margin: 0 }}>{v.registration_number}</h3>
|
<h3 style={{ fontSize: '1rem', fontWeight: 800, color: '#0F172A', margin: 0 }}>{v.registration_number}</h3>
|
||||||
<div style={{ fontSize: '0.78rem', color: '#475569', marginTop: '4px' }}>{v.brand} {v.model}</div>
|
<div style={{ fontSize: '0.78rem', color: '#475569', marginTop: '4px' }}>{v.brand} {v.model} {v.make_year ? `(${v.make_year})` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 900, background: sc.bg, color: sc.color }}>{v.status}</div>
|
<div style={{ padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 900, background: sc.bg, color: sc.color }}>{v.status}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748B', display: 'flex', gap: '12px', marginBottom: '16px' }}>
|
||||||
|
{v.ownership_type && <span>Ownership: <strong>{v.ownership_type}</strong></span>}
|
||||||
|
{v.stretcher_capacity ? <span>Capacity: <strong>{v.stretcher_capacity} Pts</strong></span> : null}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(v)}
|
onClick={() => openEditModal(v)}
|
||||||
style={{ width: '100%', padding: '9px', background: '#F1F5F9', border: 'none', borderRadius: '8px', color: '#0F172A', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer' }}
|
style={{ width: '100%', padding: '9px', background: '#F1F5F9', border: 'none', borderRadius: '8px', color: '#0F172A', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer' }}
|
||||||
|
|||||||
+160
-177
@@ -15,15 +15,12 @@ import {
|
|||||||
X,
|
X,
|
||||||
Edit2,
|
Edit2,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Eye,
|
Eye
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronsLeft,
|
|
||||||
ChevronsRight
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Card } from '../../components/Common';
|
import { Card } from '../../components/Common';
|
||||||
import { fleetApi } from '../../api/fleet';
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
import { Pagination } from '../../components/Pagination';
|
||||||
|
|
||||||
interface InventoryItem {
|
interface InventoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,6 +50,7 @@ export const FleetInventory: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string>('');
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
|
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
|
||||||
@@ -528,27 +526,72 @@ export const FleetInventory: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchInventory = async (nameFilter?: string, catFilter?: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
if (!token) {
|
||||||
|
setError('Authentication token not found. Please log in again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fleetApi.searchInventoryMaster(token, { name: nameFilter, category: catFilter });
|
||||||
|
|
||||||
|
let rawList = response?.data?.data || response?.data || (Array.isArray(response) ? response : []);
|
||||||
|
if (!rawList || rawList.length === 0) rawList = [];
|
||||||
|
|
||||||
|
const mappedList: InventoryItem[] = rawList.map((item: any, index: number) => {
|
||||||
|
const minStock = item.min_stock_threshold || 20;
|
||||||
|
const maxStock = item.max_stock_level || 100;
|
||||||
|
let totalStock: number;
|
||||||
|
if (item.totalStock !== undefined) totalStock = Number(item.totalStock);
|
||||||
|
else if (item.current_stock !== undefined) totalStock = Number(item.current_stock);
|
||||||
|
else if (item.total_stock !== undefined) totalStock = Number(item.total_stock);
|
||||||
|
else if (item.stock !== undefined) totalStock = Number(item.stock);
|
||||||
|
else if (item.quantity !== undefined) totalStock = Number(item.quantity);
|
||||||
|
else {
|
||||||
|
const isLowStock = index % 4 === 0;
|
||||||
|
totalStock = isLowStock ? Math.floor(minStock * 0.6) : Math.floor(minStock + (maxStock - minStock) * 0.4);
|
||||||
|
}
|
||||||
|
const expiringSoon = index % 5 === 0 ? Math.floor(minStock * 0.15) : 0;
|
||||||
|
const v1 = Math.floor(totalStock * 0.4);
|
||||||
|
const v2 = Math.floor(totalStock * 0.35);
|
||||||
|
const v3 = totalStock - (v1 + v2);
|
||||||
|
const vehicles = [
|
||||||
|
{ vehicleId: 'V-001', stock: v1 },
|
||||||
|
{ vehicleId: 'V-002', stock: v2 },
|
||||||
|
{ vehicleId: 'V-003', stock: v3 }
|
||||||
|
].filter(v => v.stock > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id || `ITM-00${index + 1}`,
|
||||||
|
name: item.name || 'Unknown Supply Item',
|
||||||
|
category: item.category || 'GENERAL',
|
||||||
|
totalStock, minStock, maxStock,
|
||||||
|
unit: item.unit_of_measure || 'UNITS',
|
||||||
|
expiringSoon,
|
||||||
|
supplierDetails: item.supplier_details || 'Generic Supplier Distributors',
|
||||||
|
leadTimeDays: item.lead_time_days || 3,
|
||||||
|
vehicles
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setInventory(mappedList);
|
||||||
|
if (mappedList.length > 0) setSelectedItem(mappedList[0]);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch inventory master:', err);
|
||||||
|
setError(err?.message || 'Failed to sync with tactical stock catalog.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchInventoryAndMetadata = async () => {
|
const fetchMetadata = async () => {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
if (!token) {
|
if (!token) return;
|
||||||
setError('Authentication token not found. Please log in again.');
|
const metaResponse = await fleetApi.getInventoryMetadata(token).catch(() => null);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch master list and metadata in parallel
|
|
||||||
const [response, metaResponse] = await Promise.all([
|
|
||||||
fleetApi.getInventoryMaster(token),
|
|
||||||
fleetApi.getInventoryMetadata(token).catch(e => {
|
|
||||||
console.warn('Metadata fetch failed, using fallbacks:', e);
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Process metadata response
|
|
||||||
if (metaResponse) {
|
if (metaResponse) {
|
||||||
const metaData = metaResponse?.data?.data || metaResponse?.data;
|
const metaData = metaResponse?.data?.data || metaResponse?.data;
|
||||||
if (metaData) {
|
if (metaData) {
|
||||||
@@ -557,93 +600,38 @@ export const FleetInventory: React.FC = () => {
|
|||||||
units: metaData.units || [],
|
units: metaData.units || [],
|
||||||
commonNames: metaData.common_names || {}
|
commonNames: metaData.common_names || {}
|
||||||
});
|
});
|
||||||
// Update default form category to first returned item
|
if (metaData.categories && metaData.categories.length > 0) setFormCategory(metaData.categories[0]);
|
||||||
if (metaData.categories && metaData.categories.length > 0) {
|
if (metaData.units && metaData.units.length > 0) setFormUnit(metaData.units[0]);
|
||||||
setFormCategory(metaData.categories[0]);
|
|
||||||
}
|
|
||||||
if (metaData.units && metaData.units.length > 0) {
|
|
||||||
setFormUnit(metaData.units[0]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// Handle the various structures returned by the backend
|
console.warn('Metadata fetch failed:', e);
|
||||||
let rawList = response?.data?.data || response?.data || (Array.isArray(response) ? response : []);
|
|
||||||
|
|
||||||
// Fall back to complete tactical mock catalogue if response is empty
|
|
||||||
if (!rawList || rawList.length === 0) {
|
|
||||||
rawList = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map the API fields to our UI-friendly model with rich, simulated telemetry where needed
|
|
||||||
const mappedList: InventoryItem[] = rawList.map((item: any, index: number) => {
|
|
||||||
const minStock = item.min_stock_threshold || 20;
|
|
||||||
const maxStock = item.max_stock_level || 100;
|
|
||||||
|
|
||||||
// Try to use actual backend stock if available, else fallback to deterministic mock value
|
|
||||||
let totalStock: number;
|
|
||||||
if (item.totalStock !== undefined) {
|
|
||||||
totalStock = Number(item.totalStock);
|
|
||||||
} else if (item.current_stock !== undefined) {
|
|
||||||
totalStock = Number(item.current_stock);
|
|
||||||
} else if (item.total_stock !== undefined) {
|
|
||||||
totalStock = Number(item.total_stock);
|
|
||||||
} else if (item.stock !== undefined) {
|
|
||||||
totalStock = Number(item.stock);
|
|
||||||
} else if (item.quantity !== undefined) {
|
|
||||||
totalStock = Number(item.quantity);
|
|
||||||
} else {
|
|
||||||
const isLowStock = index % 4 === 0; // 25% of items have low stock
|
|
||||||
totalStock = isLowStock
|
|
||||||
? Math.floor(minStock * 0.6)
|
|
||||||
: Math.floor(minStock + (maxStock - minStock) * 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expiring soon count
|
|
||||||
const expiringSoon = index % 5 === 0 ? Math.floor(minStock * 0.15) : 0;
|
|
||||||
|
|
||||||
// Distribute stock across mock vehicles
|
|
||||||
const v1 = Math.floor(totalStock * 0.4);
|
|
||||||
const v2 = Math.floor(totalStock * 0.35);
|
|
||||||
const v3 = totalStock - (v1 + v2);
|
|
||||||
const vehicles = [
|
|
||||||
{ vehicleId: 'V-001', stock: v1 },
|
|
||||||
{ vehicleId: 'V-002', stock: v2 },
|
|
||||||
{ vehicleId: 'V-003', stock: v3 }
|
|
||||||
].filter(v => v.stock > 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.id || `ITM-00${index + 1}`,
|
|
||||||
name: item.name || 'Unknown Supply Item',
|
|
||||||
category: item.category || 'GENERAL',
|
|
||||||
totalStock,
|
|
||||||
minStock,
|
|
||||||
maxStock,
|
|
||||||
unit: item.unit_of_measure || 'UNITS',
|
|
||||||
expiringSoon,
|
|
||||||
supplierDetails: item.supplier_details || 'Generic Supplier Distributors',
|
|
||||||
leadTimeDays: item.lead_time_days || 3,
|
|
||||||
vehicles
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setInventory(mappedList);
|
|
||||||
|
|
||||||
// Auto-select the first item on load
|
|
||||||
if (mappedList.length > 0) {
|
|
||||||
setSelectedItem(mappedList[0]);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to fetch inventory master:', err);
|
|
||||||
setError(err?.message || 'Failed to sync with tactical stock catalog.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
fetchMetadata();
|
||||||
fetchInventoryAndMetadata();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInventory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isFirstMount = React.useRef(true);
|
||||||
|
|
||||||
|
// Debounced search for name and category
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstMount.current) {
|
||||||
|
isFirstMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchInventory(searchQuery.trim() || undefined, filterCategory || undefined);
|
||||||
|
setPage(1);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, filterCategory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRequests = async () => {
|
const fetchRequests = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -723,12 +711,8 @@ export const FleetInventory: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter inventory based on search input
|
// The API now handles filtering by name and category
|
||||||
const filteredInventory = inventory.filter(item =>
|
const filteredInventory = inventory;
|
||||||
item.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
item.category?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
item.id?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredInventory.length / itemsPerPage));
|
const totalPages = Math.max(1, Math.ceil(filteredInventory.length / itemsPerPage));
|
||||||
const safePage = Math.min(page, totalPages);
|
const safePage = Math.min(page, totalPages);
|
||||||
@@ -743,37 +727,7 @@ export const FleetInventory: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="fleet-inventory animate-in fade-in duration-500">
|
<div className="fleet-inventory animate-in fade-in duration-500">
|
||||||
{/* Top Stats */}
|
{/* Top Stats */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
{/* Top Stats Removed - Moved to badge next to Warehouse Stock */}
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: 'var(--accent-cyan)' }}><Archive size={20} /></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 900, color: 'var(--accent-green)' }}>+12%</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900 }}>{loading ? '...' : totalSKUs}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>TOTAL SKUs</div>
|
|
||||||
</div>
|
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(239, 68, 68, 0.2)', background: 'rgba(239, 68, 68, 0.02)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: '#EF4444' }}><AlertTriangle size={20} /></div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#EF4444' }}>{loading ? '...' : lowStockCount}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>LOW STOCK ITEMS</div>
|
|
||||||
</div>
|
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(245, 158, 11, 0.2)', background: 'rgba(245, 158, 11, 0.02)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: '#F59E0B' }}><Clock size={20} /></div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#F59E0B' }}>{loading ? '...' : expiringSoonCount}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>EXPIRING (30D)</div>
|
|
||||||
</div>
|
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(168, 85, 247, 0.2)', background: 'rgba(168, 85, 247, 0.02)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: '#A855F7' }}><Package size={20} /></div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#A855F7' }}>{loadingRequests ? '...' : pendingRequests.length}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>PENDING REQUESTS</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||||
@@ -832,6 +786,27 @@ export const FleetInventory: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) => { setFilterCategory(e.target.value); setPage(1); }}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #CBD5E1',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{metadata.categories.map(c => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
@@ -871,6 +846,42 @@ export const FleetInventory: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Database size={16} /> WAREHOUSE STOCK
|
<Database size={16} /> WAREHOUSE STOCK
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchParams({ tab: 'pending_requests' });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
background: 'rgba(168, 85, 247, 0.1)',
|
||||||
|
border: '1px solid rgba(168, 85, 247, 0.3)',
|
||||||
|
color: '#A855F7',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(168, 85, 247, 0.2)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(168, 85, 247, 0.1)'}
|
||||||
|
>
|
||||||
|
<Package size={16} />
|
||||||
|
PENDING REQUESTS
|
||||||
|
<span style={{
|
||||||
|
background: '#A855F7',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 900,
|
||||||
|
marginLeft: '4px'
|
||||||
|
}}>
|
||||||
|
{loadingRequests ? '...' : pendingRequests.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card title="Tactical Inventory Ledger">
|
<Card title="Tactical Inventory Ledger">
|
||||||
@@ -1006,42 +1017,14 @@ export const FleetInventory: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination Controls */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 20px', borderTop: '1px solid rgba(15, 23, 42, 0.08)' }}>
|
<Pagination
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
currentPage={page}
|
||||||
<span style={{ fontSize: '0.78rem', color: '#475569' }}>
|
totalItems={filteredInventory.length}
|
||||||
Showing <strong style={{ color: '#0F172A' }}>{filteredInventory.length === 0 ? 0 : (safePage - 1) * itemsPerPage + 1}–{Math.min(safePage * itemsPerPage, filteredInventory.length)}</strong> of <strong style={{ color: '#0F172A' }}>{filteredInventory.length}</strong> items
|
itemsPerPage={itemsPerPage}
|
||||||
</span>
|
onPageChange={setPage}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
onItemsPerPageChange={setItemsPerPage}
|
||||||
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>Rows per page:</span>
|
itemsLabel="items"
|
||||||
<select
|
/>
|
||||||
value={itemsPerPage}
|
|
||||||
onChange={e => { setItemsPerPage(Number(e.target.value)); setPage(1); }}
|
|
||||||
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #CBD5E1', background: '#FFFFFF', color: '#0F172A', fontSize: '0.75rem', outline: 'none', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<option value={5}>5</option>
|
|
||||||
<option value={10}>10</option>
|
|
||||||
<option value={20}>20</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
||||||
<button onClick={() => setPage(1)} disabled={safePage === 1} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === 1 ? '#94A3B8' : '#475569', cursor: safePage === 1 ? 'not-allowed' : 'pointer' }}><ChevronsLeft size={15} /></button>
|
|
||||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={safePage === 1} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === 1 ? '#94A3B8' : '#475569', cursor: safePage === 1 ? 'not-allowed' : 'pointer' }}><ChevronLeft size={15} /></button>
|
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
||||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - safePage) <= 1)
|
|
||||||
.map((p, i, arr) => (
|
|
||||||
<React.Fragment key={p}>
|
|
||||||
{i > 0 && arr[i - 1] !== p - 1 && <span style={{ padding: '0 4px', color: '#94A3B8' }}>...</span>}
|
|
||||||
<button onClick={() => setPage(p)} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: 'none', background: p === safePage ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : '#F1F5F9', color: p === safePage ? '#fff' : '#475569', fontWeight: p === safePage ? 700 : 500, cursor: 'pointer', fontSize: '0.82rem' }}>{p}</button>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={safePage === totalPages} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === totalPages ? '#94A3B8' : '#475569', cursor: safePage === totalPages ? 'not-allowed' : 'pointer' }}><ChevronRight size={15} /></button>
|
|
||||||
<button onClick={() => setPage(totalPages)} disabled={safePage === totalPages} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === totalPages ? '#94A3B8' : '#475569', cursor: safePage === totalPages ? 'not-allowed' : 'pointer' }}><ChevronsRight size={15} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export const FleetOrganization: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = 'https://teleems-api-gateway.onrender.com/v1/fleet/stations';
|
const url = 'https://teleems-api-gateway.onrender.com/v1/fleet/stations';
|
||||||
|
|
||||||
console.log('[Stations] Calling:', url, '| token starts with:', authToken.substring(0, 15));
|
console.log('[Stations] Calling:', url, '| token starts with:', authToken.substring(0, 15));
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -186,7 +187,16 @@ export const FleetOrganization: React.FC = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
setStations(mergedList);
|
// Normalize field names: API may return station_name instead of name
|
||||||
|
const normalizedList = mergedList.map((s: any) => ({
|
||||||
|
...s,
|
||||||
|
name: s.name || s.station_name || '',
|
||||||
|
address: s.address || s.station_address || '',
|
||||||
|
incharge_name: s.incharge_name || s.incharge || '',
|
||||||
|
phone: s.phone || s.contact_phone || s.contact || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
setStations(normalizedList);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFetchError('Network error — unable to reach the server. Check your connection.');
|
setFetchError('Network error — unable to reach the server. Check your connection.');
|
||||||
console.error('Failed to fetch stations:', e);
|
console.error('Failed to fetch stations:', e);
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Search,
|
Search,
|
||||||
Check,
|
Check,
|
||||||
Truck
|
Truck,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card } from '../../components/Common';
|
import { Card } from '../../components/Common';
|
||||||
import { fleetApi } from '../../api/fleet';
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
import { Pagination } from '../../components/Pagination';
|
||||||
|
|
||||||
export const FleetPendingRequests: React.FC = () => {
|
export const FleetPendingRequests: React.FC = () => {
|
||||||
const [, setSearchParams] = useSearchParams();
|
const [, setSearchParams] = useSearchParams();
|
||||||
@@ -28,12 +28,12 @@ export const FleetPendingRequests: React.FC = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const itemsPerPage = 8;
|
const [itemsPerPage, setItemsPerPage] = useState<number>(8);
|
||||||
|
|
||||||
// Inline loading when clicking action buttons
|
// Inline loading when clicking action buttons
|
||||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchRequests = async () => {
|
const fetchRequests = async (searchFilter?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -42,8 +42,11 @@ export const FleetPendingRequests: React.FC = () => {
|
|||||||
setError('Session token expired. Please log in.');
|
setError('Session token expired. Please log in.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Fetch with specific status filter (PENDING, APPROVED, COMPLETED, or '' for all)
|
const params: any = {};
|
||||||
const res = await fleetApi.getRestockRequests(token, statusFilter || undefined);
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
if (searchFilter) params.search = searchFilter;
|
||||||
|
|
||||||
|
const res = await fleetApi.getRestockRequests(token, params);
|
||||||
const data = res?.data?.data || res?.data || [];
|
const data = res?.data?.data || res?.data || [];
|
||||||
setRequests(Array.isArray(data) ? data : []);
|
setRequests(Array.isArray(data) ? data : []);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -55,9 +58,26 @@ export const FleetPendingRequests: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFirstMount = React.useRef(true);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRequests();
|
fetchRequests();
|
||||||
}, [statusFilter]);
|
}, []);
|
||||||
|
|
||||||
|
// Debounced search and status filter
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstMount.current) {
|
||||||
|
isFirstMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchRequests(searchQuery.trim() || undefined);
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, statusFilter]);
|
||||||
|
|
||||||
const handleUpdateStatus = async (requestId: string, nextStatus: 'APPROVED' | 'COMPLETED') => {
|
const handleUpdateStatus = async (requestId: string, nextStatus: 'APPROVED' | 'COMPLETED') => {
|
||||||
if (!requestId) return;
|
if (!requestId) return;
|
||||||
@@ -76,20 +96,8 @@ export const FleetPendingRequests: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter local requests on top of status filter using search query
|
// The API now handles filtering by search and status
|
||||||
const filteredRequests = requests.filter(req => {
|
const filteredRequests = requests;
|
||||||
const itemName = req.item_master?.name || '';
|
|
||||||
const itemCategory = req.item_master?.category || '';
|
|
||||||
const requestedBy = typeof req.requested_by === 'object' && req.requested_by !== null
|
|
||||||
? (req.requested_by.name || req.requested_by.username || '')
|
|
||||||
: String(req.requested_by || '');
|
|
||||||
|
|
||||||
return (
|
|
||||||
itemName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
itemCategory.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
requestedBy.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination calculation
|
// Pagination calculation
|
||||||
const totalPages = Math.ceil(filteredRequests.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredRequests.length / itemsPerPage);
|
||||||
@@ -145,7 +153,6 @@ export const FleetPendingRequests: React.FC = () => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
}}
|
||||||
onFocus={() => setIsSearchFocused(true)}
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
onBlur={() => setIsSearchFocused(false)}
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
@@ -156,9 +163,32 @@ export const FleetPendingRequests: React.FC = () => {
|
|||||||
color: '#0F172A',
|
color: '#0F172A',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
width: '100%'
|
width: '100%',
|
||||||
|
paddingRight: searchQuery ? '24px' : '0'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '12px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#94A3B8',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '2px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#475569'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#94A3B8'}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status filters */}
|
{/* Status filters */}
|
||||||
@@ -226,166 +256,202 @@ export const FleetPendingRequests: React.FC = () => {
|
|||||||
<p style={{ fontSize: '0.8125rem' }}>There are no restock requests matching this state filter right now.</p>
|
<p style={{ fontSize: '0.8125rem' }}>There are no restock requests matching this state filter right now.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
<thead>
|
<AnimatePresence>
|
||||||
<tr style={{ textAlign: 'left', opacity: 0.5, fontSize: '0.65rem', textTransform: 'uppercase', borderBottom: '1px solid rgba(15, 23, 42, 0.08)' }}>
|
|
||||||
<th style={{ padding: '16px 12px', color: '#0F172A' }}>Requested Supply Item</th>
|
|
||||||
<th style={{ padding: '16px 12px', color: '#0F172A' }}>Quantity Demanded</th>
|
|
||||||
<th style={{ padding: '16px 12px', color: '#0F172A' }}>Requested By</th>
|
|
||||||
<th style={{ padding: '16px 12px', color: '#0F172A' }}>Request Date</th>
|
|
||||||
<th style={{ padding: '16px 12px', color: '#0F172A' }}>Status Badge</th>
|
|
||||||
<th style={{ padding: '16px 12px', color: '#0F172A', textAlign: 'right' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{currentItems.map((req) => {
|
{currentItems.map((req) => {
|
||||||
const requestedUser = typeof req.requested_by === 'object' && req.requested_by !== null
|
const requestedUser = typeof req.requested_by === 'object' && req.requested_by !== null
|
||||||
? (req.requested_by.name || req.requested_by.username || 'Unknown User')
|
? (req.requested_by.name || req.requested_by.username || 'Unknown User')
|
||||||
: (req.requested_by || 'N/A');
|
: (req.requested_by || 'N/A');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={req.id} style={{ borderBottom: '1px solid rgba(15, 23, 42, 0.04)', transition: 'background 0.2s' }} className="hover-glow">
|
<motion.div
|
||||||
<td style={{ padding: '16px 12px' }}>
|
key={req.id}
|
||||||
<div style={{ fontWeight: 700, fontSize: '0.875rem', color: '#0F172A' }}>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
{req.item_master?.name || 'Unknown Item'}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
y: -2,
|
||||||
|
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04)',
|
||||||
|
borderColor: '#CBD5E1'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Subtle status indicator line on the left */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '4px',
|
||||||
|
background: statusFilter === 'PENDING' ? '#F59E0B' : statusFilter === 'APPROVED' ? '#3B82F6' : '#10B981'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: '2', minWidth: '250px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'rgba(6, 182, 212, 0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#06B6D4'
|
||||||
|
}}>
|
||||||
|
<Package size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '2px', fontWeight: 500 }}>
|
<div>
|
||||||
Category: {req.item_master?.category || 'General'}
|
<div style={{ fontWeight: 700, fontSize: '1.05rem', color: '#0F172A', marginBottom: '4px' }}>
|
||||||
|
{req.items && req.items.length > 0 ? req.items[0].name : 'Unknown Item'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748B', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||||
|
{req.vehicle?.registration_number || 'Medical Supply'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ fontWeight: 900, fontSize: '1rem', color: '#06B6D4' }}>
|
<div style={{ flex: '1', minWidth: '120px' }}>
|
||||||
{req.quantity} <span style={{ fontSize: '0.7rem', color: '#64748B', fontWeight: 500 }}>Units</span>
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', fontWeight: 700, marginBottom: '6px', letterSpacing: '0.5px' }}>Quantity</div>
|
||||||
|
<div style={{ fontWeight: 900, fontSize: '1.25rem', color: '#06B6D4', display: 'flex', alignItems: 'baseline', gap: '4px' }}>
|
||||||
|
{req.items && req.items.length > 0 ? req.items[0].quantityRequested : 0} <span style={{ fontSize: '0.8rem', color: '#64748B', fontWeight: 600 }}>Units</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: '0.8rem', color: '#475569' }}>
|
<div style={{ flex: '1.5', minWidth: '180px' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', fontWeight: 700, marginBottom: '6px', letterSpacing: '0.5px' }}>Requested By</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: '#334155', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '26px',
|
||||||
|
height: '26px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#F1F5F9',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: 800,
|
||||||
|
border: '1px solid #E2E8F0'
|
||||||
|
}}>
|
||||||
|
{requestedUser.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
{requestedUser}
|
{requestedUser}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '6px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<td style={{ padding: '16px 12px' }}>
|
<Clock size={12} />
|
||||||
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
|
||||||
{new Date(req.createdAt || req.updatedAt).toLocaleDateString()}
|
{new Date(req.createdAt || req.updatedAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
|
<div style={{ flex: '1', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '16px', minWidth: '140px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.65rem',
|
fontSize: '0.7rem',
|
||||||
fontWeight: 900,
|
fontWeight: 800,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: statusFilter === 'PENDING' ? '#D97706' : statusFilter === 'APPROVED' ? '#2563EB' : '#16A34A',
|
color: statusFilter === 'PENDING' ? '#D97706' : statusFilter === 'APPROVED' ? '#1D4ED8' : '#15803D',
|
||||||
background: statusFilter === 'PENDING' ? 'rgba(245, 158, 11, 0.1)' : statusFilter === 'APPROVED' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(34, 197, 94, 0.1)',
|
background: statusFilter === 'PENDING' ? '#FEF3C7' : statusFilter === 'APPROVED' ? '#DBEAFE' : '#DCFCE7',
|
||||||
padding: '4px 10px',
|
padding: '6px 12px',
|
||||||
borderRadius: '6px'
|
borderRadius: '20px',
|
||||||
|
letterSpacing: '0.5px'
|
||||||
}}>
|
}}>
|
||||||
{req.status}
|
{req.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px', textAlign: 'right' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
{statusFilter === 'PENDING' && (
|
{statusFilter === 'PENDING' && (
|
||||||
<button
|
<motion.button
|
||||||
disabled={processingId !== null}
|
whileHover={{ scale: 1.05 }}
|
||||||
onClick={() => handleUpdateStatus(req.id, 'APPROVED')}
|
whileTap={{ scale: 0.95 }}
|
||||||
style={{
|
disabled={processingId !== null}
|
||||||
background: '#10B981',
|
onClick={() => handleUpdateStatus(req.id, 'APPROVED')}
|
||||||
border: 'none',
|
style={{
|
||||||
color: '#FFFFFF',
|
background: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
|
||||||
borderRadius: '8px',
|
border: 'none',
|
||||||
padding: '6px 12px',
|
color: '#FFFFFF',
|
||||||
fontSize: '0.75rem',
|
borderRadius: '10px',
|
||||||
fontWeight: 700,
|
padding: '8px 18px',
|
||||||
cursor: processingId !== null ? 'not-allowed' : 'pointer',
|
fontSize: '0.8rem',
|
||||||
display: 'inline-flex',
|
fontWeight: 700,
|
||||||
alignItems: 'center',
|
cursor: processingId !== null ? 'not-allowed' : 'pointer',
|
||||||
gap: '4px',
|
display: 'inline-flex',
|
||||||
opacity: processingId !== null ? 0.6 : 1,
|
alignItems: 'center',
|
||||||
transition: 'all 0.2s',
|
gap: '6px',
|
||||||
boxShadow: '0 2px 4px rgba(16, 185, 129, 0.15)'
|
opacity: processingId !== null ? 0.7 : 1,
|
||||||
}}
|
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.25)'
|
||||||
>
|
}}
|
||||||
{processingId === req.id ? (
|
>
|
||||||
<Activity size={12} className="spin" />
|
{processingId === req.id ? (
|
||||||
) : (
|
<Activity size={14} className="spin" />
|
||||||
<Check size={12} strokeWidth={3} />
|
) : (
|
||||||
)}
|
<Check size={14} strokeWidth={3} />
|
||||||
APPROVE
|
)}
|
||||||
</button>
|
APPROVE
|
||||||
)}
|
</motion.button>
|
||||||
{statusFilter === 'APPROVED' && (
|
)}
|
||||||
<button
|
{statusFilter === 'APPROVED' && (
|
||||||
disabled={processingId !== null}
|
<motion.button
|
||||||
onClick={() => handleUpdateStatus(req.id, 'COMPLETED')}
|
whileHover={{ scale: 1.05 }}
|
||||||
style={{
|
whileTap={{ scale: 0.95 }}
|
||||||
background: '#3B82F6',
|
disabled={processingId !== null}
|
||||||
border: 'none',
|
onClick={() => handleUpdateStatus(req.id, 'COMPLETED')}
|
||||||
color: '#FFFFFF',
|
style={{
|
||||||
borderRadius: '8px',
|
background: 'linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)',
|
||||||
padding: '6px 12px',
|
border: 'none',
|
||||||
fontSize: '0.75rem',
|
color: '#FFFFFF',
|
||||||
fontWeight: 700,
|
borderRadius: '10px',
|
||||||
cursor: processingId !== null ? 'not-allowed' : 'pointer',
|
padding: '8px 18px',
|
||||||
display: 'inline-flex',
|
fontSize: '0.8rem',
|
||||||
alignItems: 'center',
|
fontWeight: 700,
|
||||||
gap: '4px',
|
cursor: processingId !== null ? 'not-allowed' : 'pointer',
|
||||||
opacity: processingId !== null ? 0.6 : 1,
|
display: 'inline-flex',
|
||||||
transition: 'all 0.2s',
|
alignItems: 'center',
|
||||||
boxShadow: '0 2px 4px rgba(59, 130, 246, 0.15)'
|
gap: '6px',
|
||||||
}}
|
opacity: processingId !== null ? 0.7 : 1,
|
||||||
>
|
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.25)'
|
||||||
{processingId === req.id ? (
|
}}
|
||||||
<Activity size={12} className="spin" />
|
>
|
||||||
) : (
|
{processingId === req.id ? (
|
||||||
<Truck size={12} />
|
<Activity size={14} className="spin" />
|
||||||
)}
|
) : (
|
||||||
HAND OVER (COMPLETE)
|
<Truck size={14} />
|
||||||
</button>
|
)}
|
||||||
)}
|
HAND OVER
|
||||||
{statusFilter === 'COMPLETED' && (
|
</motion.button>
|
||||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', color: '#10B981', fontSize: '0.75rem', fontWeight: 700 }}>
|
)}
|
||||||
<CheckCircle size={14} /> TRANSACTION CLOSED
|
{statusFilter === 'COMPLETED' && (
|
||||||
</div>
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: '#10B981', fontSize: '0.8rem', fontWeight: 800 }}>
|
||||||
)}
|
<CheckCircle size={16} /> COMPLETED
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</AnimatePresence>
|
||||||
</table>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && filteredRequests.length > itemsPerPage && (
|
{!loading && !error && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', borderTop: '1px solid rgba(15, 23, 42, 0.08)', marginTop: '8px' }}>
|
<Pagination
|
||||||
<div style={{ fontSize: '0.8125rem', color: '#64748B' }}>
|
currentPage={currentPage}
|
||||||
Showing {indexOfFirstItem + 1} to {Math.min(indexOfLastItem, filteredRequests.length)} of {filteredRequests.length} transactions
|
totalItems={filteredRequests.length}
|
||||||
</div>
|
itemsPerPage={itemsPerPage}
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
onPageChange={setCurrentPage}
|
||||||
<button
|
onItemsPerPageChange={setItemsPerPage}
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
itemsLabel="transactions"
|
||||||
disabled={currentPage === 1}
|
/>
|
||||||
style={{
|
|
||||||
padding: '6px 12px', background: 'transparent', border: '1px solid #CBD5E1', borderRadius: '6px',
|
|
||||||
color: currentPage === 1 ? '#94A3B8' : '#475569', cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
|
||||||
display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={14} /> Prev
|
|
||||||
</button>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 8px', fontSize: '0.8125rem', fontWeight: 700, color: '#0F172A' }}>
|
|
||||||
{currentPage} / {totalPages}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px', background: 'transparent', border: '1px solid #CBD5E1', borderRadius: '6px',
|
|
||||||
color: currentPage === totalPages ? '#94A3B8' : '#475569', cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
|
|
||||||
display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Next <ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { UserPlus, Search, ShieldCheck, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, AlertCircle, Phone, Mail, X, Eye, EyeOff } from 'lucide-react';
|
import { UserPlus, Search, ShieldCheck, ChevronLeft, ChevronRight, Loader2, AlertCircle, Phone, Mail, X, Eye, EyeOff } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Pagination } from '../../components/Pagination';
|
||||||
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
|
||||||
interface Staff {
|
interface Staff {
|
||||||
id: string; type?: string; status?: string; createdAt?: string; aadhaar_number?: string;
|
id: string; type?: string; status?: string; createdAt?: string; aadhaar_number?: string;
|
||||||
@@ -19,6 +21,13 @@ const STATUS_CFG: Record<string, { label: string; color: string }> = {
|
|||||||
ON_LEAVE: { label: 'On Leave', color: '#F59E0B' }, ACTIVE: { label: 'Active', color: '#10B981' }, INACTIVE: { label: 'Inactive', color: '#EF4444' },
|
ON_LEAVE: { label: 'On Leave', color: '#F59E0B' }, ACTIVE: { label: 'Active', color: '#10B981' }, INACTIVE: { label: 'Inactive', color: '#EF4444' },
|
||||||
};
|
};
|
||||||
const FILTERS = ['ALL', 'DRIVER', 'EMT', 'DOCTOR'] as const;
|
const FILTERS = ['ALL', 'DRIVER', 'EMT', 'DOCTOR'] as const;
|
||||||
|
|
||||||
|
// Maps the UI filter key → the role string the API expects
|
||||||
|
const ROLE_API_MAP: Record<string, string> = {
|
||||||
|
DRIVER: 'Ambulance Pilot (Driver)',
|
||||||
|
EMT: 'EMT / Paramedic',
|
||||||
|
DOCTOR: 'Doctor',
|
||||||
|
};
|
||||||
const th: React.CSSProperties = { padding: '14px 18px', fontSize: '0.68rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, textAlign: 'left', borderBottom: '1px solid #CBD5E1', background: '#F1F5F9', whiteSpace: 'nowrap' };
|
const th: React.CSSProperties = { padding: '14px 18px', fontSize: '0.68rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, textAlign: 'left', borderBottom: '1px solid #CBD5E1', background: '#F1F5F9', whiteSpace: 'nowrap' };
|
||||||
|
|
||||||
const norm = (s: Staff) => {
|
const norm = (s: Staff) => {
|
||||||
@@ -59,19 +68,24 @@ export const FleetPersonnel: React.FC = () => {
|
|||||||
const resetModal = () => { setForm({ name:'', phone:'', password:'', aadhaar_number:'', license_number:'', license_category:'', license_expiry:'', qualification:'', certification_body:'', certificate_number:'', certificate_expiry:'', medical_registration_number:'', specialization:'', teleconsult_available: false }); setStaffType('DRIVER'); setSubmitErr(''); setSubmitOk(''); };
|
const resetModal = () => { setForm({ name:'', phone:'', password:'', aadhaar_number:'', license_number:'', license_category:'', license_expiry:'', qualification:'', certification_body:'', certificate_number:'', certificate_expiry:'', medical_registration_number:'', specialization:'', teleconsult_available: false }); setStaffType('DRIVER'); setSubmitErr(''); setSubmitOk(''); };
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
const fetchStaff = useCallback(async () => {
|
const fetchStaff = useCallback(async (role?: string, staffName?: string) => {
|
||||||
setLoading(true); setFetchError('');
|
setLoading(true); setFetchError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/staff', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } });
|
console.log('[Staff] Calling fleetApi with role:', role || 'ALL', 'and name:', staffName || 'none');
|
||||||
const json = await res.json();
|
const res = await fleetApi.searchStaff(token, { role, name: staffName });
|
||||||
if (res.status === 401 || res.status === 403) { setFetchError('Session expired.'); return; }
|
|
||||||
if (!res.ok) { setFetchError(json?.message || `Error ${res.status}`); return; }
|
const json = res;
|
||||||
|
|
||||||
let list: Staff[] = [];
|
let list: Staff[] = [];
|
||||||
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||||
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||||
else if (Array.isArray(json)) list = json;
|
else if (Array.isArray(json)) list = json;
|
||||||
setStaffList(list);
|
setStaffList(list);
|
||||||
} catch (e) { setFetchError('Network error.'); } finally { setLoading(false); }
|
} catch (e: any) {
|
||||||
|
setFetchError(e?.message || 'Network error.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const submitStaff = async () => {
|
const submitStaff = async () => {
|
||||||
@@ -83,26 +97,48 @@ export const FleetPersonnel: React.FC = () => {
|
|||||||
if (staffType === 'DOCTOR') professional_details = { medical_registration_number: form.medical_registration_number, specialization: form.specialization, qualification: form.qualification, teleconsult_available: form.teleconsult_available };
|
if (staffType === 'DOCTOR') professional_details = { medical_registration_number: form.medical_registration_number, specialization: form.specialization, qualification: form.qualification, teleconsult_available: form.teleconsult_available };
|
||||||
const body = { name: form.name, phone: form.phone, password: form.password, type: staffType, aadhaar_number: form.aadhaar_number, professional_details };
|
const body = { name: form.name, phone: form.phone, password: form.password, type: staffType, aadhaar_number: form.aadhaar_number, professional_details };
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/staff', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
await fleetApi.createStaff(body, token);
|
||||||
const json = await res.json();
|
|
||||||
if (!res.ok) { setSubmitErr(json?.message || `Error ${res.status}`); return; }
|
|
||||||
setSubmitOk(`${staffType} registered successfully!`);
|
setSubmitOk(`${staffType} registered successfully!`);
|
||||||
resetModal();
|
resetModal();
|
||||||
fetchStaff();
|
fetchStaff();
|
||||||
setTimeout(() => { setShowModal(false); setSubmitOk(''); }, 1500);
|
setTimeout(() => { setShowModal(false); setSubmitOk(''); }, 1500);
|
||||||
} catch { setSubmitErr('Network error.'); } finally { setSubmitting(false); }
|
} catch (e: any) {
|
||||||
|
setSubmitErr(e?.message || 'Network error.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initial load
|
||||||
useEffect(() => { fetchStaff(); }, [fetchStaff]);
|
useEffect(() => { fetchStaff(); }, [fetchStaff]);
|
||||||
|
|
||||||
|
// Re-fetch from API whenever the role filter tab changes or search clears (immediately)
|
||||||
|
useEffect(() => {
|
||||||
|
const apiRole = ROLE_API_MAP[filter];
|
||||||
|
if (!search.trim()) {
|
||||||
|
fetchStaff(apiRole);
|
||||||
|
}
|
||||||
|
}, [filter, search, fetchStaff]);
|
||||||
|
|
||||||
|
// Debounced API search for name — fires 400ms after user stops typing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!search.trim()) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const apiRole = ROLE_API_MAP[filter];
|
||||||
|
fetchStaff(apiRole, search.trim());
|
||||||
|
setPage(1);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search, filter, fetchStaff]);
|
||||||
|
|
||||||
const normalized = staffList.map(norm);
|
const normalized = staffList.map(norm);
|
||||||
const filtered = normalized.filter(s => (filter === 'ALL' || s.role === filter) && (s.name.toLowerCase().includes(search.toLowerCase()) || s.role.toLowerCase().includes(search.toLowerCase())));
|
// Local filtering is removed since search and role are handled by API. We just assign normalized.
|
||||||
|
const filtered = normalized;
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
|
||||||
const safePage = Math.min(page, totalPages);
|
const safePage = Math.min(page, totalPages);
|
||||||
const pageData = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
|
const pageData = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
|
||||||
const onDuty = normalized.filter(s => ['ON_DUTY', 'ACTIVE'].includes(s.status)).length;
|
const onDuty = normalized.filter(s => ['ON_DUTY', 'ACTIVE'].includes(s.status)).length;
|
||||||
const offDuty = normalized.filter(s => s.status === 'OFF_DUTY').length;
|
const offDuty = normalized.filter(s => s.status === 'OFF_DUTY').length;
|
||||||
const onLeave = normalized.filter(s => s.status === 'ON_LEAVE').length;
|
|
||||||
|
|
||||||
// ── DETAIL PAGE ──
|
// ── DETAIL PAGE ──
|
||||||
if (selectedRaw) {
|
if (selectedRaw) {
|
||||||
@@ -325,8 +361,8 @@ export const FleetPersonnel: React.FC = () => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 14, marginBottom: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14, marginBottom: 24 }}>
|
||||||
{[{ label: 'Total Staff', value: normalized.length, color: '#06B6D4' }, { label: 'On Duty', value: onDuty, color: '#10B981' }, { label: 'Off Duty', value: offDuty, color: '#64748B' }, { label: 'On Leave', value: onLeave, color: '#F59E0B' }].map(s => (
|
{[{ label: 'Total Staff', value: normalized.length, color: '#06B6D4' }, { label: 'On Duty', value: onDuty, color: '#10B981' }, { label: 'Off Duty', value: offDuty, color: '#64748B' }].map(s => (
|
||||||
<div key={s.label} style={{ background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: 14, padding: '18px 20px', borderLeft: `4px solid ${s.color}`, boxShadow: '0 4px 12px rgba(15, 23, 42, 0.01)' }}>
|
<div key={s.label} style={{ background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: 14, padding: '18px 20px', borderLeft: `4px solid ${s.color}`, boxShadow: '0 4px 12px rgba(15, 23, 42, 0.01)' }}>
|
||||||
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#0F172A', lineHeight: 1 }}>{loading ? '—' : s.value}</div>
|
<div style={{ fontSize: '1.8rem', fontWeight: 900, color: '#0F172A', lineHeight: 1 }}>{loading ? '—' : s.value}</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#475569', marginTop: 6, textTransform: 'uppercase', letterSpacing: '0.5px' }}>{s.label}</div>
|
<div style={{ fontSize: '0.7rem', color: '#475569', marginTop: 6, textTransform: 'uppercase', letterSpacing: '0.5px' }}>{s.label}</div>
|
||||||
@@ -362,7 +398,7 @@ export const FleetPersonnel: React.FC = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search staff..."
|
placeholder="Search staff..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); }}
|
||||||
onFocus={() => setIsSearchFocused(true)}
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
onBlur={() => setIsSearchFocused(false)}
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
className="stations-search-input"
|
className="stations-search-input"
|
||||||
@@ -378,7 +414,7 @@ export const FleetPersonnel: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSearch(''); setPage(1); }}
|
onClick={() => { setSearch(''); }}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '12px',
|
right: '12px',
|
||||||
@@ -411,7 +447,7 @@ export const FleetPersonnel: React.FC = () => {
|
|||||||
<div style={{ textAlign: 'center', padding: 40, background: 'rgba(239,68,68,0.05)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: 16 }}>
|
<div style={{ textAlign: 'center', padding: 40, background: 'rgba(239,68,68,0.05)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: 16 }}>
|
||||||
<AlertCircle size={32} color="#EF4444" style={{ marginBottom: 12 }} />
|
<AlertCircle size={32} color="#EF4444" style={{ marginBottom: 12 }} />
|
||||||
<p style={{ color: '#EF4444', fontWeight: 600, marginBottom: 12 }}>{fetchError}</p>
|
<p style={{ color: '#EF4444', fontWeight: 600, marginBottom: 12 }}>{fetchError}</p>
|
||||||
<button onClick={fetchStaff} style={{ padding: '9px 22px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 8, color: '#fff', fontWeight: 700, cursor: 'pointer' }}>↺ Retry</button>
|
<button onClick={() => fetchStaff(ROLE_API_MAP[filter], search.trim() || undefined)} style={{ padding: '9px 22px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 8, color: '#fff', fontWeight: 700, cursor: 'pointer' }}>↺ Retry</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -466,43 +502,14 @@ export const FleetPersonnel: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{/* Pagination */}
|
<Pagination
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 20px', borderTop: '1px solid rgba(15, 23, 42, 0.08)' }}>
|
currentPage={page}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
totalItems={filtered.length}
|
||||||
<span style={{ fontSize: '0.78rem', color: '#475569' }}>
|
itemsPerPage={itemsPerPage}
|
||||||
Showing <strong style={{ color: '#0F172A' }}>{filtered.length === 0 ? 0 : (safePage - 1) * itemsPerPage + 1}–{Math.min(safePage * itemsPerPage, filtered.length)}</strong> of <strong style={{ color: '#0F172A' }}>{filtered.length}</strong> staff
|
onPageChange={setPage}
|
||||||
</span>
|
onItemsPerPageChange={setItemsPerPage}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
itemsLabel="staff"
|
||||||
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>Rows per page:</span>
|
/>
|
||||||
<select
|
|
||||||
value={itemsPerPage}
|
|
||||||
onChange={e => { setItemsPerPage(Number(e.target.value)); setPage(1); }}
|
|
||||||
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #CBD5E1', background: '#FFFFFF', color: '#0F172A', fontSize: '0.75rem', outline: 'none', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<option value={5}>5</option>
|
|
||||||
<option value={10}>10</option>
|
|
||||||
<option value={20}>20</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
||||||
<button onClick={() => setPage(1)} disabled={safePage === 1} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === 1 ? '#94A3B8' : '#475569', cursor: safePage === 1 ? 'not-allowed' : 'pointer' }}><ChevronsLeft size={15} /></button>
|
|
||||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={safePage === 1} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === 1 ? '#94A3B8' : '#475569', cursor: safePage === 1 ? 'not-allowed' : 'pointer' }}><ChevronLeft size={15} /></button>
|
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
||||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - safePage) <= 1)
|
|
||||||
.map((p, i, arr) => (
|
|
||||||
<React.Fragment key={p}>
|
|
||||||
{i > 0 && arr[i - 1] !== p - 1 && <span style={{ padding: '0 4px', color: '#94A3B8' }}>...</span>}
|
|
||||||
<button onClick={() => setPage(p)} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: 'none', background: p === safePage ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : '#F1F5F9', color: p === safePage ? '#fff' : '#475569', fontWeight: p === safePage ? 700 : 500, cursor: 'pointer', fontSize: '0.82rem' }}>{p}</button>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={safePage === totalPages} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === totalPages ? '#94A3B8' : '#475569', cursor: safePage === totalPages ? 'not-allowed' : 'pointer' }}><ChevronRight size={15} /></button>
|
|
||||||
<button onClick={() => setPage(totalPages)} disabled={safePage === totalPages} style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid #E2E8F0', background: '#F1F5F9', color: safePage === totalPages ? '#94A3B8' : '#475569', cursor: safePage === totalPages ? 'not-allowed' : 'pointer' }}><ChevronsRight size={15} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .spin{animation:spin 1s linear infinite}`}</style>
|
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .spin{animation:spin 1s linear infinite}`}</style>
|
||||||
|
|||||||
@@ -375,10 +375,10 @@ export const FleetScheduling: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
{/* Vehicle identifier */}
|
{/* Vehicle identifier */}
|
||||||
<div style={{ padding: '6px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 9, display: 'flex', alignItems: 'center', gap: 7 }}>
|
<div style={{ padding: '6px 12px', background: 'linear-gradient(135deg, rgba(6,182,212,0.15), rgba(59,130,246,0.15))', border: '1px solid rgba(6,182,212,0.3)', borderRadius: 9, display: 'flex', alignItems: 'center', gap: 7, boxShadow: '0 0 10px rgba(6,182,212,0.1)' }}>
|
||||||
<Car size={14} color="#06B6D4" />
|
<Car size={14} color="#22D3EE" />
|
||||||
<span style={{ fontWeight: 800, fontSize: '0.82rem', color: '#fff' }}>{a.vehicleReg || a.vehicleId}</span>
|
<span style={{ fontWeight: 900, fontSize: '0.85rem', color: '#22D3EE', letterSpacing: '0.5px' }}>{a.vehicleReg || a.vehicleId}</span>
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#06B6D4', background: 'rgba(6,182,212,0.12)', padding: '1px 7px', borderRadius: 4 }}>{a.vehicleType}</span>
|
<span style={{ fontSize: '0.65rem', fontWeight: 800, color: '#0F172A', background: '#22D3EE', padding: '2px 8px', borderRadius: 5 }}>{a.vehicleType}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shift Badge */}
|
{/* Shift Badge */}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Search, MapPin, Truck, Activity, Navigation,
|
Search, MapPin, Truck, Activity, Navigation,
|
||||||
User, Calendar, Clock, AlertTriangle, Filter,
|
User, Calendar, Clock, AlertTriangle, Filter,
|
||||||
ChevronDown, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight
|
ChevronDown, X, ChevronLeft, ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Pagination } from '../../components/Pagination';
|
||||||
|
|
||||||
interface Vehicle {
|
interface Vehicle {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,6 +23,7 @@ interface Vehicle {
|
|||||||
|
|
||||||
export const FleetTrips: React.FC = () => {
|
export const FleetTrips: React.FC = () => {
|
||||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||||
|
const [stats, setStats] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
@@ -32,20 +34,26 @@ export const FleetTrips: React.FC = () => {
|
|||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
|
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
|
||||||
|
|
||||||
const fetchVehicles = useCallback(async () => {
|
const fetchVehicles = useCallback(async (regNumber: string = '') => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/vehicles', {
|
let url = 'https://teleems-api-gateway.onrender.com/v1/fleet/vehicles/stats';
|
||||||
|
if (regNumber) {
|
||||||
|
url += `?registration_number=${encodeURIComponent(regNumber)}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
let list: Vehicle[] = [];
|
|
||||||
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
const data = json?.data || {};
|
||||||
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
setStats(data);
|
||||||
else if (Array.isArray(json)) list = json;
|
|
||||||
setVehicles(list);
|
const activeList = data.activeVehiclesList || [];
|
||||||
|
const inactiveList = data.inactiveVehiclesList || [];
|
||||||
|
setVehicles([...activeList, ...inactiveList]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Failed to fetch vehicles for trip management:', e);
|
console.error('Failed to fetch vehicles for trip management:', e);
|
||||||
setError(e?.message || 'Failed to fetch vehicles');
|
setError(e?.message || 'Failed to fetch vehicles');
|
||||||
@@ -55,8 +63,12 @@ export const FleetTrips: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchVehicles();
|
const delayDebounceFn = setTimeout(() => {
|
||||||
}, [fetchVehicles]);
|
fetchVehicles(searchQuery);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(delayDebounceFn);
|
||||||
|
}, [searchQuery, fetchVehicles]);
|
||||||
|
|
||||||
const filteredVehicles = vehicles.filter(v => {
|
const filteredVehicles = vehicles.filter(v => {
|
||||||
const searchMatch = v.registration_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const searchMatch = v.registration_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@@ -92,29 +104,29 @@ export const FleetTrips: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
<div style={{ color: '#3B82F6' }}><Truck size={20} /></div>
|
<div style={{ color: '#3B82F6' }}><Truck size={20} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#0F172A' }}>{loading ? '...' : vehicles.length}</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#0F172A' }}>{loading ? '...' : stats?.totalVehicles ?? vehicles.length}</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Total Vehicles</div>
|
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Total Vehicles</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(239, 68, 68, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(239, 68, 68, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
<div style={{ color: '#EF4444' }}><Activity size={20} /></div>
|
<div style={{ color: '#EF4444' }}><Activity size={20} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#EF4444' }}>{loading ? '...' : activeTripsCount}</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#EF4444' }}>{loading ? '...' : stats?.busyVehiclesCount ?? activeTripsCount}</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Active Trips (Busy)</div>
|
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Active Trips (Busy)</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(34, 197, 94, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(34, 197, 94, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
<div style={{ color: '#22C55E' }}><Navigation size={20} /></div>
|
<div style={{ color: '#22C55E' }}><Navigation size={20} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#22C55E' }}>{loading ? '...' : availableCount}</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#22C55E' }}>{loading ? '...' : stats?.availableVehiclesCount ?? availableCount}</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Available Units</div>
|
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Available Units</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(245, 158, 11, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(245, 158, 11, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
<div style={{ color: '#F59E0B' }}><AlertTriangle size={20} /></div>
|
<div style={{ color: '#F59E0B' }}><AlertTriangle size={20} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#F59E0B' }}>{loading ? '...' : vehicles.filter(v => !v.activeShift).length}</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#F59E0B' }}>{loading ? '...' : stats?.inactiveVehiclesCount ?? vehicles.filter(v => !v.activeShift).length}</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>No Active Shift</div>
|
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Inactive Vehicles</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,57 +291,17 @@ export const FleetTrips: React.FC = () => {
|
|||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination Controls */}
|
||||||
{!loading && filteredVehicles.length > 0 && (
|
{!loading && filteredVehicles.length > 0 && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', background: '#FFFFFF', borderRadius: '16px', border: '1px solid #CBD5E1', marginTop: '8px' }}>
|
<div style={{ background: '#FFFFFF', borderRadius: '16px', border: '1px solid #CBD5E1', marginTop: '8px', overflow: 'hidden' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<Pagination
|
||||||
<span style={{ fontSize: '0.875rem', color: '#64748B' }}>Rows per page:</span>
|
currentPage={page}
|
||||||
<select
|
totalItems={filteredVehicles.length}
|
||||||
value={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
onChange={(e) => { setItemsPerPage(Number(e.target.value)); setPage(1); }}
|
onPageChange={setPage}
|
||||||
style={{ background: '#F8FAFC', border: '1px solid #E2E8F0', borderRadius: '8px', padding: '6px 28px 6px 12px', fontSize: '0.875rem', color: '#0F172A', outline: 'none', cursor: 'pointer', appearance: 'none' }}
|
onItemsPerPageChange={setItemsPerPage}
|
||||||
>
|
itemsLabel="vehicles"
|
||||||
{[5, 10, 20, 50].map(size => (
|
/>
|
||||||
<option key={size} value={size}>{size}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
|
||||||
<span style={{ fontSize: '0.875rem', color: '#64748B' }}>
|
|
||||||
{Math.min((safePage - 1) * itemsPerPage + 1, filteredVehicles.length)} - {Math.min(safePage * itemsPerPage, filteredVehicles.length)} of {filteredVehicles.length}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(1)}
|
|
||||||
disabled={safePage === 1}
|
|
||||||
style={{ padding: '6px', borderRadius: '6px', border: 'none', background: 'transparent', color: safePage === 1 ? '#CBD5E1' : '#64748B', cursor: safePage === 1 ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
<ChevronsLeft size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={safePage === 1}
|
|
||||||
style={{ padding: '6px', borderRadius: '6px', border: 'none', background: 'transparent', color: safePage === 1 ? '#CBD5E1' : '#64748B', cursor: safePage === 1 ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={safePage === totalPages}
|
|
||||||
style={{ padding: '6px', borderRadius: '6px', border: 'none', background: 'transparent', color: safePage === totalPages ? '#CBD5E1' : '#64748B', cursor: safePage === totalPages ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
<ChevronRight size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(totalPages)}
|
|
||||||
disabled={safePage === totalPages}
|
|
||||||
style={{ padding: '6px', borderRadius: '6px', border: 'none', background: 'transparent', color: safePage === totalPages ? '#CBD5E1' : '#64748B', cursor: safePage === totalPages ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
<ChevronsRight size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
.spin { animation: spin 1s linear infinite; }
|
.spin { animation: spin 1s linear infinite; }
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Database, Search, Activity, AlertTriangle, ArrowLeft, X, ChevronLeft, ChevronRight, ShoppingCart, Truck, PlusCircle, Package, ClipboardList } from 'lucide-react';
|
import { Database, Search, Activity, AlertTriangle, ArrowLeft, X, ShoppingCart, Truck, PlusCircle, Package, ClipboardList } from 'lucide-react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { fleetApi } from '../../api/fleet';
|
import { fleetApi } from '../../api/fleet';
|
||||||
import { Card } from '../../components/Common';
|
import { Card } from '../../components/Common';
|
||||||
|
import { Pagination } from '../../components/Pagination';
|
||||||
|
|
||||||
export const FleetWarehouseStock: React.FC = () => {
|
export const FleetWarehouseStock: React.FC = () => {
|
||||||
const [, setSearchParams] = useSearchParams();
|
const [, setSearchParams] = useSearchParams();
|
||||||
@@ -11,9 +12,10 @@ export const FleetWarehouseStock: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterCategory, setFilterCategory] = useState('');
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 10;
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
// Master Inventory for dropdowns
|
// Master Inventory for dropdowns
|
||||||
const [inventory, setInventory] = useState<any[]>([]);
|
const [inventory, setInventory] = useState<any[]>([]);
|
||||||
@@ -68,12 +70,12 @@ export const FleetWarehouseStock: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchStock = async () => {
|
const fetchStock = async (nameFilter?: string, catFilter?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
const response = await fleetApi.getWarehouseStock(token);
|
const response = await fleetApi.searchWarehouseStock(token, { name: nameFilter, category: catFilter });
|
||||||
const data = response?.data?.data?.data || response?.data?.data || response?.data || [];
|
const data = response?.data?.data?.data || response?.data?.data || response?.data || [];
|
||||||
setStockList(Array.isArray(data) ? data : []);
|
setStockList(Array.isArray(data) ? data : []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -84,11 +86,27 @@ export const FleetWarehouseStock: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFirstMount = React.useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStock();
|
fetchStock();
|
||||||
fetchRequests();
|
fetchRequests();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Debounced search for name and category
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstMount.current) {
|
||||||
|
isFirstMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchStock(searchQuery.trim() || undefined, filterCategory || undefined);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, filterCategory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchInventory = async () => {
|
const fetchInventory = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -212,15 +230,7 @@ export const FleetWarehouseStock: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const filteredStock = stockList;
|
||||||
setCurrentPage(1);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
const filteredStock = stockList.filter(item => {
|
|
||||||
const name = item.item_master?.name || '';
|
|
||||||
const category = item.item_master?.category || '';
|
|
||||||
return name.toLowerCase().includes(searchQuery.toLowerCase()) || category.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredStock.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredStock.length / itemsPerPage);
|
||||||
const indexOfLastItem = currentPage * itemsPerPage;
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
@@ -294,6 +304,28 @@ export const FleetWarehouseStock: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Category Dropdown */}
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #CBD5E1',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{Array.from(new Set(inventory.map(item => item.category).filter(Boolean))).map(c => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const initialItemId = inventory.length > 0 ? inventory[0].id : '';
|
const initialItemId = inventory.length > 0 ? inventory[0].id : '';
|
||||||
@@ -455,39 +487,15 @@ export const FleetWarehouseStock: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && filteredStock.length > itemsPerPage && (
|
{!loading && !error && filteredStock.length > 0 && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', borderTop: '1px solid rgba(15, 23, 42, 0.08)', marginTop: '8px' }}>
|
<Pagination
|
||||||
<div style={{ fontSize: '0.8125rem', color: '#64748B' }}>
|
currentPage={currentPage}
|
||||||
Showing {indexOfFirstItem + 1} to {Math.min(indexOfLastItem, filteredStock.length)} of {filteredStock.length} items
|
totalItems={filteredStock.length}
|
||||||
</div>
|
itemsPerPage={itemsPerPage}
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
onPageChange={setCurrentPage}
|
||||||
<button
|
onItemsPerPageChange={setItemsPerPage}
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
itemsLabel="items"
|
||||||
disabled={currentPage === 1}
|
/>
|
||||||
style={{
|
|
||||||
padding: '6px 12px', background: 'transparent', border: '1px solid #CBD5E1', borderRadius: '6px',
|
|
||||||
color: currentPage === 1 ? '#94A3B8' : '#475569', cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
|
||||||
display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={14} /> Prev
|
|
||||||
</button>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 8px', fontSize: '0.8125rem', fontWeight: 700, color: '#0F172A' }}>
|
|
||||||
{currentPage} / {totalPages}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px', background: 'transparent', border: '1px solid #CBD5E1', borderRadius: '6px',
|
|
||||||
color: currentPage === totalPages ? '#94A3B8' : '#475569', cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
|
|
||||||
display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Next <ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -433,52 +433,6 @@ export const LiveDashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle simulation trigger
|
|
||||||
const runMoveSimulation = () => {
|
|
||||||
// FindPune Vehicle or first with valid coords
|
|
||||||
const activeV = vehicles.find(v => parseFloat(v.gps_lat as string) > 0);
|
|
||||||
if (!activeV) return;
|
|
||||||
|
|
||||||
let currentLat = parseFloat(activeV.gps_lat as string);
|
|
||||||
let currentLon = parseFloat(activeV.gps_lon as string);
|
|
||||||
|
|
||||||
// Simulate standard movement increments
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
currentLat += (Math.random() - 0.5) * 0.0015;
|
|
||||||
currentLon += (Math.random() - 0.5) * 0.0015;
|
|
||||||
|
|
||||||
// Send to Socket receiver locally to test standard workflow
|
|
||||||
if (socketRef.current) {
|
|
||||||
socketRef.current.emit('simulate_move_debug', {
|
|
||||||
vehicle_id: activeV.id,
|
|
||||||
registration_number: activeV.registration_number,
|
|
||||||
lat: currentLat,
|
|
||||||
lon: currentLon
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locally update as fallback to show live feedback immediately
|
|
||||||
const marker = markersRef.current[activeV.id];
|
|
||||||
if (marker) {
|
|
||||||
const newPos = new google.maps.LatLng(currentLat, currentLon);
|
|
||||||
marker.setPosition(newPos);
|
|
||||||
if (mapInstanceRef.current) mapInstanceRef.current.panTo(newPos);
|
|
||||||
}
|
|
||||||
|
|
||||||
setVehicles(prev => {
|
|
||||||
const idx = prev.findIndex(v => v.id === activeV.id);
|
|
||||||
if (idx === -1) return prev;
|
|
||||||
const copy = [...prev];
|
|
||||||
copy[idx] = { ...copy[idx], gps_lat: currentLat, gps_lon: currentLon };
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
}, 1200);
|
|
||||||
|
|
||||||
// Clear simulation after 10 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
clearInterval(interval);
|
|
||||||
}, 10000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = vehicles.filter(v =>
|
const filtered = vehicles.filter(v =>
|
||||||
v.registration_number.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
v.registration_number.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@@ -655,32 +609,12 @@ export const LiveDashboard: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simulate Movement Button */}
|
{/* Refresh Button */}
|
||||||
<div style={{ padding: '16px 20px', background: '#F8FAFC', borderTop: '1px solid rgba(15, 23, 42, 0.06)', display: 'flex', gap: '8px' }}>
|
<div style={{ padding: '16px 20px', background: '#F8FAFC', borderTop: '1px solid rgba(15, 23, 42, 0.06)', display: 'flex' }}>
|
||||||
<button
|
|
||||||
onClick={runMoveSimulation}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '10px',
|
|
||||||
background: '#0F172A',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '10px',
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
boxShadow: '0 2px 8px rgba(15, 23, 42, 0.08)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Play size={14} /> SIMULATE MOVE (DEBUG)
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={fetchVehicles}
|
onClick={fetchVehicles}
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
color: '#475569',
|
color: '#475569',
|
||||||
@@ -689,10 +623,13 @@ export const LiveDashboard: React.FC = () => {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center'
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.8rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} /> Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user