add remaining fleet operator functionality

This commit is contained in:
2026-06-08 12:32:20 +05:30
parent 6ab819e74f
commit 6b409c10fe
16 changed files with 1176 additions and 761 deletions
+135 -26
View File
@@ -15,10 +15,12 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"recharts": "^3.8.1"
"recharts": "^3.8.1",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/google.maps": "^3.64.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -60,6 +62,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -256,27 +259,6 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -797,6 +779,29 @@
"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": {
"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",
@@ -835,6 +840,12 @@
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
"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": {
"version": "1.1.0",
"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",
"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": {
"version": "7.0.15",
"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",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"dev": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -948,6 +967,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1008,6 +1028,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -1275,6 +1296,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1375,6 +1397,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -1618,7 +1641,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -1657,6 +1679,28 @@
"integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==",
"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": {
"version": "1.45.1",
"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",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -2521,8 +2566,7 @@
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/nanoid": {
"version": "3.3.11",
@@ -2642,6 +2686,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2699,6 +2744,7 @@
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2707,6 +2753,7 @@
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -2724,6 +2771,7 @@
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -2807,7 +2855,8 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -2910,6 +2959,34 @@
"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": {
"version": "1.2.1",
"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",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3108,6 +3186,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true,
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -3204,6 +3283,35 @@
"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": {
"version": "3.1.1",
"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",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+53 -2
View File
@@ -21,10 +21,18 @@ export const fleetApi = {
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) => {
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) => {
return apiClient.post('/v1/fleet/vehicles', vehicleData, { token });
},
@@ -34,6 +42,10 @@ export const fleetApi = {
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) => {
return apiClient.patch(`/v1/fleet/vehicles/${vehicleId}`, vehicleData, { token });
},
@@ -47,6 +59,23 @@ export const fleetApi = {
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) => {
return apiClient.get('/v1/fleet/roster', { token });
},
@@ -63,6 +92,15 @@ export const fleetApi = {
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) => {
return apiClient.post('/v1/fleet/inventory/master', payload, { token });
},
@@ -84,6 +122,15 @@ export const fleetApi = {
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) => {
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 });
},
getRestockRequests: async (token: string, status?: string) => {
const url = status ? `/v1/fleet/inventory/restock-requests?status=${status}` : '/v1/fleet/inventory/restock-requests';
getRestockRequests: async (token: string, params?: { status?: string; search?: string }) => {
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 });
},
+139
View File
@@ -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>
);
};
+2 -2
View File
@@ -98,7 +98,7 @@ export const FleetLogin = () => {
localStorage.setItem('teleems_user', JSON.stringify({ ...fallback, roles: ['Fleet Operator'] }));
}
navigate('/fleet-operator?tab=organization');
navigate('/fleet-operator?tab=overview');
}
} else {
setShowError(loginJson?.message || 'Access Denied: Invalid Credentials');
@@ -128,7 +128,7 @@ export const FleetLogin = () => {
const userToStore = { ...baseUser, roles, mfa_enabled: true };
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
navigate('/fleet-operator?tab=organization');
navigate('/fleet-operator?tab=overview');
} else {
setShowError('Invalid Security Token');
}
+1 -8
View File
@@ -120,14 +120,7 @@ export const FleetOperatorDashboard: React.FC = () => {
</div>
<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' }}>
<Bell size={18} />
<span style={{ position: 'absolute', top: 6, right: 6, width: '7px', height: '7px', background: '#EF4444', borderRadius: '50%', border: '1px solid #040B16' }} />
+91 -51
View File
@@ -1,6 +1,7 @@
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 { useNavigate } from 'react-router-dom';
interface ActiveShift {
id: string;
@@ -28,6 +29,7 @@ interface ActiveShift {
}
export const FleetActiveShifts: React.FC = () => {
const navigate = useNavigate();
const [shifts, setShifts] = useState<ActiveShift[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -61,99 +63,137 @@ export const FleetActiveShifts: React.FC = () => {
}, [fetchActiveShifts]);
return (
<div style={{ color: '#F8FAFC', fontFamily: 'Inter, sans-serif' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 24 }}>
<div className="animate-in fade-in duration-500" style={{ color: '#0F172A', fontFamily: 'Inter, sans-serif' }}>
<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
onClick={fetchActiveShifts}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 12px',
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10, color: '#94A3B8', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer'
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
background: '#FFFFFF', border: '1px solid #E2E8F0',
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>
</div>
{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 }}>
{error}
<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' }}>
<Activity size={16} /> {error}
</div>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
<Loader2 size={32} color="#06B6D4" className="spin" />
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', minHeight: 300, gap: '16px' }}>
<Loader2 size={36} color="#06B6D4" className="spin" />
<span style={{ color: '#64748B', fontWeight: 600, fontSize: '0.9rem' }}>Fetching active shift telemetries...</span>
</div>
) : 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 }}>
<CheckCircle size={36} color="#10B981" style={{ marginBottom: 12 }} />
<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>
<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={48} color="#10B981" style={{ marginBottom: 16, opacity: 0.8 }} />
<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 style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '20px' }}>
{shifts.map((shift, i) => (
<motion.div
key={shift.id}
initial={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
transition={{ delay: i * 0.05, duration: 0.3 }}
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 16,
padding: '18px 24px',
background: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '20px',
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 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
{/* 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 }}>
<Car size={14} color="#06B6D4" />
<span style={{ fontWeight: 800, fontSize: '0.82rem', color: '#000000' }}>
{/* Top Accent Line */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '4px', background: 'linear-gradient(90deg, #06B6D4, #3B82F6)' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<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'}
</span>
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#000000', background: 'rgba(6,182,212,0.12)', padding: '1px 7px', borderRadius: 4 }}>
{shift.vehicle?.vehicle_type || 'N/A'}
</span>
</div>
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: '#06B6D4', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
{shift.vehicle?.vehicle_type || 'N/A Type'}
</div>
</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>
{/* 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={{ fontSize: '0.72rem', fontWeight: 600, color: '#64748B', display: 'flex', alignItems: 'center', gap: 4 }}>
<Calendar size={12} /> Started: {new Date(shift.startTime).toLocaleString()}
</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>
{/* 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 */}
<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 }}>
<User size={15} color="#06B6D4" />
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', padding: '14px', background: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '14px' }}>
<div style={{ width: '36px', height: '36px', borderRadius: '50%', background: '#F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<User size={18} color="#64748B" />
</div>
<div>
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>Pilot Driver</div>
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#000000' }}>
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#0F172A' }}>
{shift.driver?.user?.name || 'Driver Profile'}
</div>
<div style={{ fontSize: '0.75rem', color: '#64748B', fontWeight: 600 }}>Pilot / Driver</div>
</div>
</div>
{/* 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 }}>
<Activity size={15} color="#3B82F6" />
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', padding: '14px', background: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '14px' }}>
<div style={{ width: '36px', height: '36px', borderRadius: '50%', background: '#EFF6FF', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Activity size={18} color="#3B82F6" />
</div>
<div>
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>
{shift.staff?.type === 'DOCTOR' ? 'Medical Doctor' : 'Emergency Medical Technician'}
</div>
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#000000' }}>
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#0F172A' }}>
{shift.staff?.user?.name || 'EMT/Staff Profile'}
</div>
<div style={{ fontSize: '0.75rem', color: '#3B82F6', fontWeight: 600 }}>
{shift.staff?.type === 'DOCTOR' ? 'Medical Doctor' : 'Emergency Medical Tech'}
</div>
</div>
</div>
</div>
+21 -9
View File
@@ -31,6 +31,7 @@ import {
} from 'recharts';
import { motion } from 'framer-motion';
import { fleetApi } from '../../api/fleet';
import { Pagination } from '../../components/Pagination';
interface APIVehicle {
id: string;
@@ -53,6 +54,8 @@ export const FleetAnalytics: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [timeRange, setTimeRange] = useState<'7D' | '30D' | 'ALL'>('ALL');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(5);
const token = localStorage.getItem('teleems_token') || '';
@@ -220,19 +223,18 @@ export const FleetAnalytics: React.FC = () => {
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 28, color: '#F8FAFC' }}>
{/* HEADER CONTROLS */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<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></div>
<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 }}>
@@ -542,7 +544,7 @@ export const FleetAnalytics: React.FC = () => {
</tr>
</thead>
<tbody>
{vehicles.map((v) => {
{currentVehicles.map((v) => {
// Extract active driver/staff from shift or roster
const driverName = v.activeShift?.driver?.user?.name || v.activeRoster?.driver?.user?.name || '—';
const driverPhone = v.activeShift?.driver?.user?.phone || v.activeRoster?.driver?.user?.phone || '';
@@ -604,6 +606,16 @@ export const FleetAnalytics: React.FC = () => {
</tbody>
</table>
</div>
{vehicles.length > 0 && (
<Pagination
currentPage={safePage}
totalItems={vehicles.length}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
onItemsPerPageChange={setItemsPerPage}
itemsLabel="vehicles"
/>
)}
</div>
</div>
+168 -80
View File
@@ -4,6 +4,7 @@ import {
AlertCircle, ChevronDown, Edit2
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { fleetApi } from '../../api/fleet';
interface Station {
id: string;
@@ -17,6 +18,10 @@ interface Vehicle {
brand?: string;
model?: string;
chassis_number?: string;
engine_number?: string;
make_year?: number;
stretcher_capacity?: number;
ownership_type?: string;
station_id?: string;
status?: string;
}
@@ -28,9 +33,14 @@ interface VehicleForm {
brand: string;
model: 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 OWNERSHIP_TYPES = ['self-owned', 'hospital-owned', 'leased'];
const EMPTY_FORM: VehicleForm = {
registration_number: '',
@@ -39,6 +49,10 @@ const EMPTY_FORM: VehicleForm = {
brand: '',
model: '',
station_id: '',
engine_number: '',
make_year: '',
stretcher_capacity: '',
ownership_type: 'self-owned',
};
const cardStyle: React.CSSProperties = {
@@ -104,10 +118,7 @@ export const FleetAssets: React.FC = () => {
const fetchStations = useCallback(async () => {
setStationsLoading(true);
try {
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/stations', {
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
});
const json = await res.json();
const json = await fleetApi.getStations(token);
let list: Station[] = [];
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.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 () => {
setLoading(true);
try {
const url = selectedStationFilter
? `https://teleems-api-gateway.onrender.com/v1/fleet/vehicles?station_id=${selectedStationFilter}`
: 'https://teleems-api-gateway.onrender.com/v1/fleet/vehicles';
const json = selectedStationFilter
? await fleetApi.getVehiclesByStationName(token, selectedStationFilter)
: 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[] = [];
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.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]);
// 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 = () => {
setForm(EMPTY_FORM);
setShowModal(true);
@@ -159,6 +187,10 @@ export const FleetAssets: React.FC = () => {
brand: v.brand || '',
model: v.model || '',
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);
};
@@ -175,23 +207,20 @@ export const FleetAssets: React.FC = () => {
chassis_number: editForm.chassis_number || undefined,
brand: editForm.brand,
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(
`https://teleems-api-gateway.onrender.com/v1/fleet/vehicles/${editingVehicle.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) {
const json = await fleetApi.updateVehicleDetails(editingVehicle.id, payload, token);
if (!json) return;
if (!json.status || json.status < 400 || json?.id || json?.data?.id) {
showToast('success', 'Vehicle updated successfully!');
setShowEditModal(false);
setEditingVehicle(null);
fetchVehicles();
} 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) {
showToast('error', err?.message || 'Network error.');
@@ -215,25 +244,21 @@ export const FleetAssets: React.FC = () => {
chassis_number: form.chassis_number || undefined,
brand: form.brand,
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', {
method: 'POST',
headers: {
'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) {
const json = await fleetApi.createVehicle(payload, token);
if (!json) return;
if (!json.status || json.status < 400 || json?.id || json?.data?.id) {
showToast('success', `Vehicle "${form.registration_number}" registered successfully!`);
setShowModal(false);
setForm(EMPTY_FORM);
fetchVehicles();
} 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) {
showToast('error', err?.message || 'Network error. Please check your connection.');
@@ -242,11 +267,12 @@ export const FleetAssets: React.FC = () => {
}
};
const filtered = vehicles.filter(v =>
v.registration_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.brand?.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.model?.toLowerCase().includes(searchQuery.toLowerCase())
);
// When search is cleared, reload via fetchVehicles (respects station filter)
useEffect(() => {
if (!searchQuery.trim()) {
fetchVehicles();
}
}, [searchQuery, fetchVehicles]);
const getStatusColor = (status?: string) => {
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}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
<div>
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div>
<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 style={{ position: 'relative' }}>
<label style={labelStyle}>Vehicle Type *</label>
<select style={selectStyle} required value={form.vehicle_type} onChange={e => setForm(f => ({ ...f, vehicle_type: e.target.value }))}>
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</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>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div style={{ position: 'relative' }}>
<label style={labelStyle}>Vehicle Type *</label>
<select style={selectStyle} required value={form.vehicle_type} onChange={e => setForm(f => ({ ...f, vehicle_type: e.target.value }))}>
{VEHICLE_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 style={{ position: 'relative' }}>
<label style={labelStyle}>Ownership Type</label>
<select style={selectStyle} value={form.ownership_type} onChange={e => setForm(f => ({ ...f, ownership_type: e.target.value }))}>
{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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<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 }))} />
</div>
</div>
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div>
<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 style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
@@ -367,22 +422,35 @@ export const FleetAssets: React.FC = () => {
<form onSubmit={handleUpdate}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
<div>
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div>
<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 style={{ position: 'relative' }}>
<label style={labelStyle}>Vehicle Type *</label>
<select style={selectStyle} required value={editForm.vehicle_type} onChange={e => setEditForm(f => ({ ...f, vehicle_type: e.target.value }))}>
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</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>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div style={{ position: 'relative' }}>
<label style={labelStyle}>Vehicle Type *</label>
<select style={selectStyle} required value={editForm.vehicle_type} onChange={e => setEditForm(f => ({ ...f, vehicle_type: e.target.value }))}>
{VEHICLE_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 style={{ position: 'relative' }}>
<label style={labelStyle}>Ownership Type</label>
<select style={selectStyle} value={editForm.ownership_type} onChange={e => setEditForm(f => ({ ...f, ownership_type: e.target.value }))}>
{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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<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 }))} />
</div>
</div>
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div>
<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 style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
@@ -488,7 +572,7 @@ export const FleetAssets: React.FC = () => {
>
<option value="">📁 All Stations</option>
{stations.map(s => (
<option key={s.id} value={s.id}>📍 {s.name}</option>
<option key={s.id} value={s.name}>📍 {s.name}</option>
))}
</select>
<ChevronDown
@@ -519,16 +603,16 @@ export const FleetAssets: React.FC = () => {
</div>
)}
{!loading && filtered.length === 0 && (
{!loading && vehicles.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#64748B' }}>
<Truck size={48} style={{ opacity: 0.2, marginBottom: '16px' }} />
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#475569', marginBottom: '8px' }}>No Vehicles Found</h3>
</div>
)}
{!loading && filtered.length > 0 && (
{!loading && vehicles.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
{filtered.map(v => {
{vehicles.map(v => {
const sc = getStatusColor(v.status);
return (
<motion.div
@@ -540,10 +624,14 @@ export const FleetAssets: React.FC = () => {
<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>
<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 style={{ padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 900, background: sc.bg, color: sc.color }}>{v.status}</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
onClick={() => openEditModal(v)}
style={{ width: '100%', padding: '9px', background: '#F1F5F9', border: 'none', borderRadius: '8px', color: '#0F172A', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer' }}
+160 -177
View File
@@ -15,15 +15,12 @@ import {
X,
Edit2,
PlusCircle,
Eye,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight
Eye
} from 'lucide-react';
import { motion } from 'framer-motion';
import { Card } from '../../components/Common';
import { fleetApi } from '../../api/fleet';
import { Pagination } from '../../components/Pagination';
interface InventoryItem {
id: string;
@@ -53,6 +50,7 @@ export const FleetInventory: React.FC = () => {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [filterCategory, setFilterCategory] = useState<string>('');
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
const [page, setPage] = useState<number>(1);
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(() => {
const fetchInventoryAndMetadata = async () => {
setLoading(true);
setError(null);
const fetchMetadata = async () => {
try {
const token = localStorage.getItem('teleems_token') || '';
if (!token) {
setError('Authentication token not found. Please log in again.');
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 (!token) return;
const metaResponse = await fleetApi.getInventoryMetadata(token).catch(() => null);
if (metaResponse) {
const metaData = metaResponse?.data?.data || metaResponse?.data;
if (metaData) {
@@ -557,93 +600,38 @@ export const FleetInventory: React.FC = () => {
units: metaData.units || [],
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.units && metaData.units.length > 0) {
setFormUnit(metaData.units[0]);
}
if (metaData.categories && metaData.categories.length > 0) setFormCategory(metaData.categories[0]);
if (metaData.units && metaData.units.length > 0) setFormUnit(metaData.units[0]);
}
}
// Handle the various structures returned by the backend
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);
} catch (e) {
console.warn('Metadata fetch failed:', e);
}
};
fetchInventoryAndMetadata();
fetchMetadata();
}, []);
// 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(() => {
const fetchRequests = async () => {
try {
@@ -723,12 +711,8 @@ export const FleetInventory: React.FC = () => {
}
};
// Filter inventory based on search input
const filteredInventory = inventory.filter(item =>
item.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.category?.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.id?.toLowerCase().includes(searchQuery.toLowerCase())
);
// The API now handles filtering by name and category
const filteredInventory = inventory;
const totalPages = Math.max(1, Math.ceil(filteredInventory.length / itemsPerPage));
const safePage = Math.min(page, totalPages);
@@ -743,37 +727,7 @@ export const FleetInventory: React.FC = () => {
return (
<div className="fleet-inventory animate-in fade-in duration-500">
{/* Top Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
<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>
{/* Top Stats Removed - Moved to badge next to Warehouse Stock */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
@@ -832,6 +786,27 @@ export const FleetInventory: React.FC = () => {
</button>
)}
</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
onClick={() => {
setSubmitError(null);
@@ -871,6 +846,42 @@ export const FleetInventory: React.FC = () => {
>
<Database size={16} /> WAREHOUSE STOCK
</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>
<Card title="Tactical Inventory Ledger">
@@ -1006,42 +1017,14 @@ export const FleetInventory: React.FC = () => {
</table>
{/* Pagination Controls */}
<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' }}>{filteredInventory.length === 0 ? 0 : (safePage - 1) * itemsPerPage + 1}{Math.min(safePage * itemsPerPage, filteredInventory.length)}</strong> of <strong style={{ color: '#0F172A' }}>{filteredInventory.length}</strong> items
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<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>
<Pagination
currentPage={page}
totalItems={filteredInventory.length}
itemsPerPage={itemsPerPage}
onPageChange={setPage}
onItemsPerPageChange={setItemsPerPage}
itemsLabel="items"
/>
</>
)}
</Card>
+11 -1
View File
@@ -114,6 +114,7 @@ export const FleetOrganization: React.FC = () => {
}
const url = 'https://teleems-api-gateway.onrender.com/v1/fleet/stations';
console.log('[Stations] Calling:', url, '| token starts with:', authToken.substring(0, 15));
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) {
setFetchError('Network error — unable to reach the server. Check your connection.');
console.error('Failed to fetch stations:', e);
+228 -162
View File
@@ -7,15 +7,15 @@ import {
Package,
CheckCircle,
Clock,
ChevronLeft,
ChevronRight,
Search,
Check,
Truck
Truck,
X
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card } from '../../components/Common';
import { fleetApi } from '../../api/fleet';
import { Pagination } from '../../components/Pagination';
export const FleetPendingRequests: React.FC = () => {
const [, setSearchParams] = useSearchParams();
@@ -28,12 +28,12 @@ export const FleetPendingRequests: React.FC = () => {
const [searchQuery, setSearchQuery] = useState<string>('');
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number>(1);
const itemsPerPage = 8;
const [itemsPerPage, setItemsPerPage] = useState<number>(8);
// Inline loading when clicking action buttons
const [processingId, setProcessingId] = useState<string | null>(null);
const fetchRequests = async () => {
const fetchRequests = async (searchFilter?: string) => {
setLoading(true);
setError(null);
try {
@@ -42,8 +42,11 @@ export const FleetPendingRequests: React.FC = () => {
setError('Session token expired. Please log in.');
return;
}
// Fetch with specific status filter (PENDING, APPROVED, COMPLETED, or '' for all)
const res = await fleetApi.getRestockRequests(token, statusFilter || undefined);
const params: any = {};
if (statusFilter) params.status = statusFilter;
if (searchFilter) params.search = searchFilter;
const res = await fleetApi.getRestockRequests(token, params);
const data = res?.data?.data || res?.data || [];
setRequests(Array.isArray(data) ? data : []);
setCurrentPage(1);
@@ -55,9 +58,26 @@ export const FleetPendingRequests: React.FC = () => {
}
};
const isFirstMount = React.useRef(true);
// Initial load
useEffect(() => {
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') => {
if (!requestId) return;
@@ -76,20 +96,8 @@ export const FleetPendingRequests: React.FC = () => {
}
};
// Filter local requests on top of status filter using search query
const filteredRequests = requests.filter(req => {
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())
);
});
// The API now handles filtering by search and status
const filteredRequests = requests;
// Pagination calculation
const totalPages = Math.ceil(filteredRequests.length / itemsPerPage);
@@ -145,7 +153,6 @@ export const FleetPendingRequests: React.FC = () => {
value={searchQuery}
onChange={e => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
@@ -156,9 +163,32 @@ export const FleetPendingRequests: React.FC = () => {
color: '#0F172A',
fontSize: '0.875rem',
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>
{/* 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>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<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>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<AnimatePresence>
{currentItems.map((req) => {
const requestedUser = typeof req.requested_by === 'object' && req.requested_by !== null
? (req.requested_by.name || req.requested_by.username || 'Unknown User')
: (req.requested_by || 'N/A');
return (
<tr key={req.id} style={{ borderBottom: '1px solid rgba(15, 23, 42, 0.04)', transition: 'background 0.2s' }} className="hover-glow">
<td style={{ padding: '16px 12px' }}>
<div style={{ fontWeight: 700, fontSize: '0.875rem', color: '#0F172A' }}>
{req.item_master?.name || 'Unknown Item'}
<motion.div
key={req.id}
initial={{ opacity: 0, y: 10 }}
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 style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '2px', fontWeight: 500 }}>
Category: {req.item_master?.category || 'General'}
<div>
<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>
</td>
<td style={{ padding: '16px 12px' }}>
<div style={{ fontWeight: 900, fontSize: '1rem', color: '#06B6D4' }}>
{req.quantity} <span style={{ fontSize: '0.7rem', color: '#64748B', fontWeight: 500 }}>Units</span>
</div>
<div style={{ flex: '1', minWidth: '120px' }}>
<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>
</td>
<td style={{ padding: '16px 12px' }}>
<div style={{ fontWeight: 600, fontSize: '0.8rem', color: '#475569' }}>
</div>
<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}
</div>
</td>
<td style={{ padding: '16px 12px' }}>
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
<div style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '6px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Clock size={12} />
{new Date(req.createdAt || req.updatedAt).toLocaleDateString()}
</div>
</td>
<td style={{ padding: '16px 12px' }}>
</div>
<div style={{ flex: '1', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '16px', minWidth: '140px' }}>
<span style={{
fontSize: '0.65rem',
fontWeight: 900,
fontSize: '0.7rem',
fontWeight: 800,
textTransform: 'uppercase',
color: statusFilter === 'PENDING' ? '#D97706' : statusFilter === 'APPROVED' ? '#2563EB' : '#16A34A',
background: statusFilter === 'PENDING' ? 'rgba(245, 158, 11, 0.1)' : statusFilter === 'APPROVED' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(34, 197, 94, 0.1)',
padding: '4px 10px',
borderRadius: '6px'
color: statusFilter === 'PENDING' ? '#D97706' : statusFilter === 'APPROVED' ? '#1D4ED8' : '#15803D',
background: statusFilter === 'PENDING' ? '#FEF3C7' : statusFilter === 'APPROVED' ? '#DBEAFE' : '#DCFCE7',
padding: '6px 12px',
borderRadius: '20px',
letterSpacing: '0.5px'
}}>
{req.status}
</span>
</td>
<td style={{ padding: '16px 12px', textAlign: 'right' }}>
{statusFilter === 'PENDING' && (
<button
disabled={processingId !== null}
onClick={() => handleUpdateStatus(req.id, 'APPROVED')}
style={{
background: '#10B981',
border: 'none',
color: '#FFFFFF',
borderRadius: '8px',
padding: '6px 12px',
fontSize: '0.75rem',
fontWeight: 700,
cursor: processingId !== null ? 'not-allowed' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
opacity: processingId !== null ? 0.6 : 1,
transition: 'all 0.2s',
boxShadow: '0 2px 4px rgba(16, 185, 129, 0.15)'
}}
>
{processingId === req.id ? (
<Activity size={12} className="spin" />
) : (
<Check size={12} strokeWidth={3} />
)}
APPROVE
</button>
)}
{statusFilter === 'APPROVED' && (
<button
disabled={processingId !== null}
onClick={() => handleUpdateStatus(req.id, 'COMPLETED')}
style={{
background: '#3B82F6',
border: 'none',
color: '#FFFFFF',
borderRadius: '8px',
padding: '6px 12px',
fontSize: '0.75rem',
fontWeight: 700,
cursor: processingId !== null ? 'not-allowed' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
opacity: processingId !== null ? 0.6 : 1,
transition: 'all 0.2s',
boxShadow: '0 2px 4px rgba(59, 130, 246, 0.15)'
}}
>
{processingId === req.id ? (
<Activity size={12} className="spin" />
) : (
<Truck size={12} />
)}
HAND OVER (COMPLETE)
</button>
)}
{statusFilter === 'COMPLETED' && (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', color: '#10B981', fontSize: '0.75rem', fontWeight: 700 }}>
<CheckCircle size={14} /> TRANSACTION CLOSED
</div>
)}
</td>
</tr>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
{statusFilter === 'PENDING' && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
disabled={processingId !== null}
onClick={() => handleUpdateStatus(req.id, 'APPROVED')}
style={{
background: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
border: 'none',
color: '#FFFFFF',
borderRadius: '10px',
padding: '8px 18px',
fontSize: '0.8rem',
fontWeight: 700,
cursor: processingId !== null ? 'not-allowed' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
opacity: processingId !== null ? 0.7 : 1,
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.25)'
}}
>
{processingId === req.id ? (
<Activity size={14} className="spin" />
) : (
<Check size={14} strokeWidth={3} />
)}
APPROVE
</motion.button>
)}
{statusFilter === 'APPROVED' && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
disabled={processingId !== null}
onClick={() => handleUpdateStatus(req.id, 'COMPLETED')}
style={{
background: 'linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)',
border: 'none',
color: '#FFFFFF',
borderRadius: '10px',
padding: '8px 18px',
fontSize: '0.8rem',
fontWeight: 700,
cursor: processingId !== null ? 'not-allowed' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
opacity: processingId !== null ? 0.7 : 1,
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.25)'
}}
>
{processingId === req.id ? (
<Activity size={14} className="spin" />
) : (
<Truck size={14} />
)}
HAND OVER
</motion.button>
)}
{statusFilter === 'COMPLETED' && (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: '#10B981', fontSize: '0.8rem', fontWeight: 800 }}>
<CheckCircle size={16} /> COMPLETED
</div>
)}
</div>
</div>
</motion.div>
);
})}
</tbody>
</table>
</AnimatePresence>
</div>
)}
{!loading && !error && filteredRequests.length > itemsPerPage && (
<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 {indexOfFirstItem + 1} to {Math.min(indexOfLastItem, filteredRequests.length)} of {filteredRequests.length} transactions
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
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>
{!loading && !error && (
<Pagination
currentPage={currentPage}
totalItems={filteredRequests.length}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
onItemsPerPageChange={setItemsPerPage}
itemsLabel="transactions"
/>
)}
</Card>
</div>
+62 -55
View File
@@ -1,6 +1,8 @@
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 { Pagination } from '../../components/Pagination';
import { fleetApi } from '../../api/fleet';
interface Staff {
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' },
};
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 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 token = localStorage.getItem('teleems_token') || '';
const fetchStaff = useCallback(async () => {
const fetchStaff = useCallback(async (role?: string, staffName?: string) => {
setLoading(true); setFetchError('');
try {
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/staff', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } });
const json = await res.json();
if (res.status === 401 || res.status === 403) { setFetchError('Session expired.'); return; }
if (!res.ok) { setFetchError(json?.message || `Error ${res.status}`); return; }
console.log('[Staff] Calling fleetApi with role:', role || 'ALL', 'and name:', staffName || 'none');
const res = await fleetApi.searchStaff(token, { role, name: staffName });
const json = res;
let list: Staff[] = [];
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;
setStaffList(list);
} catch (e) { setFetchError('Network error.'); } finally { setLoading(false); }
} catch (e: any) {
setFetchError(e?.message || 'Network error.');
} finally {
setLoading(false);
}
}, [token]);
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 };
const body = { name: form.name, phone: form.phone, password: form.password, type: staffType, aadhaar_number: form.aadhaar_number, professional_details };
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) });
const json = await res.json();
if (!res.ok) { setSubmitErr(json?.message || `Error ${res.status}`); return; }
await fleetApi.createStaff(body, token);
setSubmitOk(`${staffType} registered successfully!`);
resetModal();
fetchStaff();
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]);
// 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 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 safePage = Math.min(page, totalPages);
const pageData = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
const onDuty = normalized.filter(s => ['ON_DUTY', 'ACTIVE'].includes(s.status)).length;
const offDuty = normalized.filter(s => s.status === 'OFF_DUTY').length;
const onLeave = normalized.filter(s => s.status === 'ON_LEAVE').length;
// ── DETAIL PAGE ──
if (selectedRaw) {
@@ -325,8 +361,8 @@ export const FleetPersonnel: React.FC = () => {
</AnimatePresence>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,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 => (
<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' }].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 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>
@@ -362,7 +398,7 @@ export const FleetPersonnel: React.FC = () => {
type="text"
placeholder="Search staff..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
onChange={(e) => { setSearch(e.target.value); }}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
className="stations-search-input"
@@ -378,7 +414,7 @@ export const FleetPersonnel: React.FC = () => {
/>
{search && (
<button
onClick={() => { setSearch(''); setPage(1); }}
onClick={() => { setSearch(''); }}
style={{
position: 'absolute',
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 }}>
<AlertCircle size={32} color="#EF4444" style={{ marginBottom: 12 }} />
<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>
)}
@@ -466,43 +502,14 @@ export const FleetPersonnel: React.FC = () => {
})}
</tbody>
</table>
{/* Pagination */}
<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' }}>{filtered.length === 0 ? 0 : (safePage - 1) * itemsPerPage + 1}{Math.min(safePage * itemsPerPage, filtered.length)}</strong> of <strong style={{ color: '#0F172A' }}>{filtered.length}</strong> staff
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<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>
<Pagination
currentPage={page}
totalItems={filtered.length}
itemsPerPage={itemsPerPage}
onPageChange={setPage}
onItemsPerPageChange={setItemsPerPage}
itemsLabel="staff"
/>
</div>
)}
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .spin{animation:spin 1s linear infinite}`}</style>
+4 -4
View File
@@ -375,10 +375,10 @@ export const FleetScheduling: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
{/* 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 }}>
<Car size={14} color="#06B6D4" />
<span style={{ fontWeight: 800, fontSize: '0.82rem', color: '#fff' }}>{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>
<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="#22D3EE" />
<span style={{ fontWeight: 900, fontSize: '0.85rem', color: '#22D3EE', letterSpacing: '0.5px' }}>{a.vehicleReg || a.vehicleId}</span>
<span style={{ fontSize: '0.65rem', fontWeight: 800, color: '#0F172A', background: '#22D3EE', padding: '2px 8px', borderRadius: 5 }}>{a.vehicleType}</span>
</div>
{/* Shift Badge */}
+37 -65
View File
@@ -2,9 +2,10 @@ import React, { useState, useEffect, useCallback } from 'react';
import {
Search, MapPin, Truck, Activity, Navigation,
User, Calendar, Clock, AlertTriangle, Filter,
ChevronDown, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight
ChevronDown, X, ChevronLeft, ChevronRight
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Pagination } from '../../components/Pagination';
interface Vehicle {
id: string;
@@ -22,6 +23,7 @@ interface Vehicle {
export const FleetTrips: React.FC = () => {
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
@@ -32,20 +34,26 @@ export const FleetTrips: React.FC = () => {
const [page, setPage] = useState<number>(1);
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
const fetchVehicles = useCallback(async () => {
const fetchVehicles = useCallback(async (regNumber: string = '') => {
setLoading(true);
setError(null);
try {
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' },
});
const json = await res.json();
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);
const data = json?.data || {};
setStats(data);
const activeList = data.activeVehiclesList || [];
const inactiveList = data.inactiveVehiclesList || [];
setVehicles([...activeList, ...inactiveList]);
} catch (e: any) {
console.error('Failed to fetch vehicles for trip management:', e);
setError(e?.message || 'Failed to fetch vehicles');
@@ -55,8 +63,12 @@ export const FleetTrips: React.FC = () => {
}, []);
useEffect(() => {
fetchVehicles();
}, [fetchVehicles]);
const delayDebounceFn = setTimeout(() => {
fetchVehicles(searchQuery);
}, 500);
return () => clearTimeout(delayDebounceFn);
}, [searchQuery, fetchVehicles]);
const filteredVehicles = vehicles.filter(v => {
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={{ color: '#3B82F6' }}><Truck size={20} /></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>
<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={{ color: '#EF4444' }}><Activity size={20} /></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>
<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={{ color: '#22C55E' }}><Navigation size={20} /></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>
<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={{ color: '#F59E0B' }}><AlertTriangle size={20} /></div>
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#F59E0B' }}>{loading ? '...' : 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: '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 }}>Inactive Vehicles</div>
</div>
</div>
@@ -279,57 +291,17 @@ export const FleetTrips: React.FC = () => {
{/* Pagination Controls */}
{!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={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '0.875rem', color: '#64748B' }}>Rows per page:</span>
<select
value={itemsPerPage}
onChange={(e) => { setItemsPerPage(Number(e.target.value)); setPage(1); }}
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' }}
>
{[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 style={{ background: '#FFFFFF', borderRadius: '16px', border: '1px solid #CBD5E1', marginTop: '8px', overflow: 'hidden' }}>
<Pagination
currentPage={page}
totalItems={filteredVehicles.length}
itemsPerPage={itemsPerPage}
onPageChange={setPage}
onItemsPerPageChange={setItemsPerPage}
itemsLabel="vehicles"
/>
</div>
</div>
)}
)}
<style>{`
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
+54 -46
View File
@@ -1,9 +1,10 @@
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 { motion, AnimatePresence } from 'framer-motion';
import { fleetApi } from '../../api/fleet';
import { Card } from '../../components/Common';
import { Pagination } from '../../components/Pagination';
export const FleetWarehouseStock: React.FC = () => {
const [, setSearchParams] = useSearchParams();
@@ -11,9 +12,10 @@ export const FleetWarehouseStock: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [itemsPerPage, setItemsPerPage] = useState(10);
// Master Inventory for dropdowns
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);
setError(null);
try {
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 || [];
setStockList(Array.isArray(data) ? data : []);
} catch (err: any) {
@@ -84,11 +86,27 @@ export const FleetWarehouseStock: React.FC = () => {
}
};
const isFirstMount = React.useRef(true);
useEffect(() => {
fetchStock();
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(() => {
const fetchInventory = async () => {
try {
@@ -212,15 +230,7 @@ export const FleetWarehouseStock: React.FC = () => {
}
};
useEffect(() => {
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 filteredStock = stockList;
const totalPages = Math.ceil(filteredStock.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
@@ -294,6 +304,28 @@ export const FleetWarehouseStock: React.FC = () => {
</button>
)}
</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
onClick={() => {
const initialItemId = inventory.length > 0 ? inventory[0].id : '';
@@ -455,39 +487,15 @@ export const FleetWarehouseStock: React.FC = () => {
</table>
)}
{!loading && !error && filteredStock.length > itemsPerPage && (
<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 {indexOfFirstItem + 1} to {Math.min(indexOfLastItem, filteredStock.length)} of {filteredStock.length} items
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
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>
{!loading && !error && filteredStock.length > 0 && (
<Pagination
currentPage={currentPage}
totalItems={filteredStock.length}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
onItemsPerPageChange={setItemsPerPage}
itemsLabel="items"
/>
)}
</Card>
+8 -71
View File
@@ -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 =>
v.registration_number.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -655,32 +609,12 @@ export const LiveDashboard: React.FC = () => {
})}
</div>
{/* Simulate Movement Button */}
<div style={{ padding: '16px 20px', background: '#F8FAFC', borderTop: '1px solid rgba(15, 23, 42, 0.06)', display: 'flex', gap: '8px' }}>
<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>
{/* Refresh Button */}
<div style={{ padding: '16px 20px', background: '#F8FAFC', borderTop: '1px solid rgba(15, 23, 42, 0.06)', display: 'flex' }}>
<button
onClick={fetchVehicles}
style={{
flex: 1,
padding: '10px',
background: '#FFFFFF',
color: '#475569',
@@ -689,10 +623,13 @@ export const LiveDashboard: React.FC = () => {
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
gap: '6px',
fontWeight: 600,
fontSize: '0.8rem'
}}
>
<RefreshCw size={14} />
<RefreshCw size={14} /> Refresh
</button>
</div>