Compare commits
2 Commits
main
...
Fleet_Oper
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ab819e74f | |||
| 64a41be96b |
@@ -17,10 +17,12 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1",
|
||||||
|
"socket.io-client": "^4.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/google.maps": "^3.64.0",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
def count_tags(file_path):
|
|
||||||
with open(file_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
div_open = content.count('<div')
|
|
||||||
div_close = content.count('</div>')
|
|
||||||
motion_div_open = content.count('<motion.div')
|
|
||||||
motion_div_close = content.count('</motion.div>')
|
|
||||||
paren_open = content.count('(')
|
|
||||||
paren_close = content.count(')')
|
|
||||||
brace_open = content.count('{')
|
|
||||||
brace_close = content.count('}')
|
|
||||||
|
|
||||||
print(f"div: open={div_open}, close={div_close}, diff={div_open - div_close}")
|
|
||||||
print(f"motion.div: open={motion_div_open}, close={motion_div_close}, diff={motion_div_open - motion_div_close}")
|
|
||||||
print(f"parentheses: open={paren_open}, close={paren_close}, diff={paren_open - paren_close}")
|
|
||||||
print(f"braces: open={brace_open}, close={brace_close}, diff={brace_open - brace_close}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
count_tags(sys.argv[1])
|
|
||||||
24
src/App.tsx
24
src/App.tsx
@@ -21,7 +21,6 @@ import { FleetLogin } from './pages/FleetLogin';
|
|||||||
import { FleetOperatorDashboard } from './pages/FleetOperatorDashboard';
|
import { FleetOperatorDashboard } from './pages/FleetOperatorDashboard';
|
||||||
import { PerspectiveLauncher } from './pages/PerspectiveLauncher';
|
import { PerspectiveLauncher } from './pages/PerspectiveLauncher';
|
||||||
import { RoleLogin } from './pages/RoleLogin';
|
import { RoleLogin } from './pages/RoleLogin';
|
||||||
import { HospitalLogin } from './pages/HospitalLogin';
|
|
||||||
import { ComingSoonPortal } from './pages/ComingSoonPortal';
|
import { ComingSoonPortal } from './pages/ComingSoonPortal';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@@ -34,6 +33,9 @@ import {
|
|||||||
|
|
||||||
import { isTokenExpired, logout } from './utils/auth';
|
import { isTokenExpired, logout } from './utils/auth';
|
||||||
|
|
||||||
|
// Normalise any role string to lowercase_underscore for comparison
|
||||||
|
const normaliseRole = (r: string) => r.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
|
||||||
// --- ROLE-BASED ACCESS CONTROL ---
|
// --- ROLE-BASED ACCESS CONTROL ---
|
||||||
const RoleProtectedRoute: React.FC<{
|
const RoleProtectedRoute: React.FC<{
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
@@ -44,15 +46,14 @@ const RoleProtectedRoute: React.FC<{
|
|||||||
|
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
const userRoles = Array.isArray(user?.roles)
|
const userRoles = Array.isArray(user?.roles) ? user.roles : [];
|
||||||
? user.roles.map((r: any) => String(r).toUpperCase().replace(/\s+/g, '_'))
|
const hasAccess = allowedRoles.some(allowed =>
|
||||||
: [];
|
userRoles.some((r: string) => normaliseRole(r) === normaliseRole(allowed))
|
||||||
const hasAccess = allowedRoles.some(role => userRoles.includes(role)) || userRoles.includes('CURESELECT_ADMIN');
|
) || userRoles.some((r: string) => normaliseRole(r) === 'cureselect_admin');
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
console.log('[RBAC] Access Denied:', { required: allowedRoles, current: userRoles });
|
if (userRoles.some((r: string) => normaliseRole(r) === 'fleet_operator'))
|
||||||
// Redirect to their respective "home" if they don't have access
|
return <Navigate to="/fleet-operator" replace />;
|
||||||
if (userRoles.includes('FLEET_OPERATOR')) return <Navigate to="/fleet-operator" replace />;
|
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ function AppContent() {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const isLoginPage = location.pathname.startsWith('/login') || location.pathname === '/fleet-login' || location.pathname === '/launcher';
|
const isLoginPage = location.pathname.startsWith('/login') || location.pathname === '/fleet-login' || location.pathname === '/launcher';
|
||||||
|
const isFleetPage = location.pathname.startsWith('/fleet-operator');
|
||||||
|
|
||||||
const isAuthenticated = localStorage.getItem('teleems_auth') === 'true';
|
const isAuthenticated = localStorage.getItem('teleems_auth') === 'true';
|
||||||
const user = JSON.parse(localStorage.getItem('teleems_user') || '{}');
|
const user = JSON.parse(localStorage.getItem('teleems_user') || '{}');
|
||||||
@@ -125,7 +127,6 @@ function AppContent() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PerspectiveLauncher />} />
|
<Route path="/" element={<PerspectiveLauncher />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/login/hospital" element={<HospitalLogin />} />
|
|
||||||
<Route path="/login/:role" element={<RoleLogin />} />
|
<Route path="/login/:role" element={<RoleLogin />} />
|
||||||
<Route path="/fleet-login" element={<FleetLogin />} />
|
<Route path="/fleet-login" element={<FleetLogin />} />
|
||||||
<Route path="/launcher" element={<PerspectiveLauncher />} />
|
<Route path="/launcher" element={<PerspectiveLauncher />} />
|
||||||
@@ -146,13 +147,14 @@ function AppContent() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<div className="scanline" />
|
<div className="scanline" />
|
||||||
<TopBar />
|
{!isFleetPage && <TopBar />}
|
||||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
isAuthenticated ? (
|
isAuthenticated ? (
|
||||||
user?.roles?.includes('FLEET_OPERATOR') && !user?.roles?.includes('CURESELECT_ADMIN')
|
user?.roles?.some((r: string) => normaliseRole(r) === 'fleet_operator') &&
|
||||||
|
!user?.roles?.some((r: string) => normaliseRole(r) === 'cureselect_admin')
|
||||||
? <Navigate to="/fleet-operator" replace />
|
? <Navigate to="/fleet-operator" replace />
|
||||||
: <Dashboard />
|
: <Dashboard />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -25,9 +25,60 @@ export const apiClient = {
|
|||||||
defaultHeaders['Authorization'] = `Bearer ${authToken}`;
|
defaultHeaders['Authorization'] = `Bearer ${authToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MOCK BYPASS FOR DEMO SESSIONS ---
|
||||||
|
if (authToken && (
|
||||||
|
authToken.startsWith('mock-') ||
|
||||||
|
authToken.startsWith('dev-token-') ||
|
||||||
|
authToken === 'dev-super-token-2026'
|
||||||
|
)) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (endpoint.includes('/v1/incidents')) {
|
||||||
|
resolve({
|
||||||
|
status: 200,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'INC-MOCK-001',
|
||||||
|
category: 'MEDICAL',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
status: 'PENDING',
|
||||||
|
address: 'Sector 7G, Tactical Hub',
|
||||||
|
notes: 'High-priority mock incident for system validation.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
gps_lat: 13.0827,
|
||||||
|
gps_lon: 80.2707,
|
||||||
|
patients: [{ name: 'Tactical Test', age: 34, gender: 'Male', symptoms: ['None'], triage_code: 'RED' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else if (endpoint.includes('/v1/auth/users')) {
|
||||||
|
resolve({
|
||||||
|
status: 200,
|
||||||
|
data: [
|
||||||
|
{ id: 'u1', username: 'admin', roles: ['CURESELECT_ADMIN'], status: 'ACTIVE', email: 'admin@teleems.com' },
|
||||||
|
{ id: 'u2', username: 'fleet_op', roles: ['FLEET_OPERATOR'], status: 'ACTIVE', email: 'fleet@teleems.com' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else if (endpoint.includes('/v1/auth/audit-logs')) {
|
||||||
|
resolve({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
logs: [
|
||||||
|
{ id: 'l1', action: 'LOGIN_SUCCESS', createdAt: new Date().toISOString(), ipAddress: '127.0.0.1', user: { username: 'admin' } },
|
||||||
|
{ id: 'l2', action: 'INCIDENT_VIEW', createdAt: new Date().toISOString(), ipAddress: '127.0.0.1', user: { username: 'admin' } }
|
||||||
|
],
|
||||||
|
total: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({ status: 200, data: [] });
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`;
|
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`;
|
||||||
console.log(`[API] ${options.method || 'GET'} ${url}`);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { ...defaultHeaders, ...headers },
|
headers: { ...defaultHeaders, ...headers },
|
||||||
@@ -35,8 +86,8 @@ export const apiClient = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle session expiration
|
// Handle session expiration
|
||||||
if (response.status === 401 && !url.includes('/auth/login')) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
console.warn('Token expired or invalid. Triggering auto-logout...');
|
console.warn('Unauthorized request detected. Triggering auto-logout...');
|
||||||
logout();
|
logout();
|
||||||
return null; // Return null as the app will redirect
|
return null; // Return null as the app will redirect
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ export const authApi = {
|
|||||||
return apiClient.post('/v1/auth/mfa/totp/verify', { totp_code: totpCode }, { token });
|
return apiClient.post('/v1/auth/mfa/totp/verify', { totp_code: totpCode }, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
getProfile: async (token: string): Promise<AuthUser> => {
|
|
||||||
return apiClient.get('/v1/auth/me', { token });
|
|
||||||
},
|
|
||||||
|
|
||||||
getAuditLogs: async (token: string, limit = 20, offset = 0) => {
|
getAuditLogs: async (token: string, limit = 20, offset = 0) => {
|
||||||
return apiClient.get(`/v1/auth/audit-logs?limit=${limit}&offset=${offset}`, { token });
|
return apiClient.get(`/v1/auth/audit-logs?limit=${limit}&offset=${offset}`, { token });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export const fleetApi = {
|
|||||||
return apiClient.post('/v1/fleet/stations', stationData, { token });
|
return apiClient.post('/v1/fleet/stations', stationData, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateStation: async (stationId: string, stationData: any, token: string) => {
|
||||||
|
return apiClient.patch(`/v1/fleet/stations/${stationId}`, stationData, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
getStationDetails: async (stationId: string, token: string) => {
|
||||||
|
return apiClient.get(`/v1/fleet/stations/${stationId}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
getStations: async (token: string, organisationId?: string) => {
|
getStations: async (token: string, organisationId?: string) => {
|
||||||
const url = organisationId
|
const url = organisationId
|
||||||
? `/v1/fleet/stations?organisationId=${organisationId}`
|
? `/v1/fleet/stations?organisationId=${organisationId}`
|
||||||
@@ -13,12 +21,17 @@ export const fleetApi = {
|
|||||||
return apiClient.get(url, { token });
|
return apiClient.get(url, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getStationVehicles: async (token: string, stationId: string) => {
|
||||||
|
return apiClient.get(`/v1/fleet/vehicles?station_id=${stationId}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
createVehicle: async (vehicleData: any, token: string) => {
|
createVehicle: async (vehicleData: any, token: string) => {
|
||||||
return apiClient.post('/v1/fleet/vehicles', vehicleData, { token });
|
return apiClient.post('/v1/fleet/vehicles', vehicleData, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
getVehicles: async (token: string, orgId: string) => {
|
getVehicles: async (token: string, orgId?: string) => {
|
||||||
return apiClient.get(`/v1/fleet/vehicles?org_id=${orgId}`, { token });
|
const url = orgId ? `/v1/fleet/vehicles?org_id=${orgId}` : `/v1/fleet/vehicles`;
|
||||||
|
return apiClient.get(url, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateVehicleDetails: async (vehicleId: string, vehicleData: any, token: string) => {
|
updateVehicleDetails: async (vehicleId: string, vehicleData: any, token: string) => {
|
||||||
@@ -29,8 +42,13 @@ export const fleetApi = {
|
|||||||
return apiClient.post('/v1/fleet/staff', staffData, { token });
|
return apiClient.post('/v1/fleet/staff', staffData, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
getStaff: async (token: string, orgId: string) => {
|
getStaff: async (token: string, orgId?: string) => {
|
||||||
return apiClient.get(`/v1/fleet/staff?organisationId=${orgId}`, { token });
|
const url = orgId ? `/v1/fleet/staff?organisationId=${orgId}` : `/v1/fleet/staff`;
|
||||||
|
return apiClient.get(url, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
getRoster: async (token: string) => {
|
||||||
|
return apiClient.get('/v1/fleet/roster', { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
createRoster: async (rosterData: any, token: string) => {
|
createRoster: async (rosterData: any, token: string) => {
|
||||||
@@ -43,5 +61,43 @@ export const fleetApi = {
|
|||||||
|
|
||||||
getInventoryMaster: async (token: string) => {
|
getInventoryMaster: async (token: string) => {
|
||||||
return apiClient.get('/v1/fleet/inventory/master', { token });
|
return apiClient.get('/v1/fleet/inventory/master', { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
createInventoryMaster: async (payload: any[], token: string) => {
|
||||||
|
return apiClient.post('/v1/fleet/inventory/master', payload, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateInventoryMaster: async (itemId: string, payload: any, token: string) => {
|
||||||
|
return apiClient.patch(`/v1/fleet/inventory/master/${itemId}`, payload, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
restockInventory: async (payload: any[], token: string) => {
|
||||||
|
return apiClient.post('/v1/fleet/inventory/warehouse', payload, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
getInventoryMetadata: async (token: string, category?: string) => {
|
||||||
|
const url = category ? `/v1/fleet/inventory/metadata?category=${category}` : '/v1/fleet/inventory/metadata';
|
||||||
|
return apiClient.get(url, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
getWarehouseStock: async (token: string) => {
|
||||||
|
return apiClient.get('/v1/fleet/inventory/warehouse', { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
assignToVehicle: async (vehicleId: string, payload: any, token: string) => {
|
||||||
|
return apiClient.post(`/v1/fleet/vehicles/${vehicleId}/inventory/bulk`, payload, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
getPendingRestockRequests: async (token: string) => {
|
||||||
|
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';
|
||||||
|
return apiClient.get(url, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRestockRequestStatus: async (requestId: string, status: string, token: string) => {
|
||||||
|
return apiClient.patch(`/v1/fleet/inventory/restock-requests/${requestId}/status`, { status }, { token });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import { apiClient } from './apiClient';
|
|
||||||
import { authApi } from './auth';
|
|
||||||
import type { LoginResponse } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hospital-specific API module.
|
|
||||||
* Wraps auth endpoints with hospital-context logic.
|
|
||||||
*/
|
|
||||||
export const hospitalApi = {
|
|
||||||
/**
|
|
||||||
* Authenticate a hospital user via the real auth API.
|
|
||||||
*/
|
|
||||||
login: async (username: string, password: string): Promise<LoginResponse> => {
|
|
||||||
return authApi.login(username, password);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify MFA for hospital user.
|
|
||||||
*/
|
|
||||||
verifyMfa: async (mfaSessionToken: string, totpCode: string): Promise<LoginResponse> => {
|
|
||||||
return authApi.verifyMfa(mfaSessionToken, totpCode);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get hospital profile data from the authenticated user metadata.
|
|
||||||
*/
|
|
||||||
getProfile: async (token: string) => {
|
|
||||||
return apiClient.get('/v1/hospital/ops/profile', { token });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get departments scoped to a specific hospital.
|
|
||||||
*/
|
|
||||||
getDepartments: async (token: string, hospitalId?: string) => {
|
|
||||||
const endpoint = hospitalId
|
|
||||||
? `/v1/hospital/departments?hospitalId=${hospitalId}`
|
|
||||||
: '/v1/hospital/departments';
|
|
||||||
return apiClient.get(endpoint, { token });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get staff members scoped to a specific hospital.
|
|
||||||
*/
|
|
||||||
getStaff: async (token: string, hospitalId?: string) => {
|
|
||||||
const res = await authApi.getUsers(token);
|
|
||||||
if (res?.data && hospitalId) {
|
|
||||||
const filtered = (Array.isArray(res.data) ? res.data : []).filter(
|
|
||||||
(u: any) => u.hospitalId === hospitalId || u.organisationId === hospitalId
|
|
||||||
);
|
|
||||||
return { ...res, data: filtered };
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new department under a hospital.
|
|
||||||
*/
|
|
||||||
createDepartment: async (deptData: any, token: string) => {
|
|
||||||
return authApi.createDepartment(deptData, token);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a staff member under a hospital.
|
|
||||||
*/
|
|
||||||
registerStaff: async (staffData: any, token: string) => {
|
|
||||||
return authApi.registerUser(staffData, token);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateProfile: async (payload: any, token: string) => {
|
|
||||||
return apiClient.patch('/v1/hospital/ops/profile', payload, { token });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get incoming operations/dispatches for the hospital.
|
|
||||||
*/
|
|
||||||
getIncomingOperations: async (token: string, hospitalId?: string, status?: string) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (hospitalId) params.set('hospitalId', hospitalId);
|
|
||||||
if (status) params.set('status', status);
|
|
||||||
const qs = params.toString();
|
|
||||||
const url = `/v1/hospital/ops/incoming${qs ? '?' + qs : ''}`;
|
|
||||||
return apiClient.get(url, { token });
|
|
||||||
},
|
|
||||||
|
|
||||||
admitPatient: async (patientId: string, departmentId: string, token: string) => {
|
|
||||||
return apiClient.patch(`/v1/hospital/ops/incoming/${patientId}/admit`, { departmentId, bedType: 'General' }, { token });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all admitted patients for this hospital.
|
|
||||||
*/
|
|
||||||
getAdmissions: async (token: string, page?: number, limit?: number, status?: string) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (page) params.set('page', String(page));
|
|
||||||
if (limit) params.set('limit', String(limit));
|
|
||||||
if (status) params.set('status', status);
|
|
||||||
const qs = params.toString();
|
|
||||||
return apiClient.get(`/v1/hospital/ops/admissions${qs ? '?' + qs : ''}`, { token });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discharge a patient by admission ID.
|
|
||||||
*/
|
|
||||||
dischargePatient: async (admissionId: string, token: string) => {
|
|
||||||
return apiClient.patch(`/v1/hospital/ops/admissions/${admissionId}/discharge`, {}, { token });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const PerspectiveSwitcher: React.FC<{ currentRole: string; onSwitch: (rol
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const currentPerspective = perspectives.find(p => p.id === currentRole) || perspectives[0];
|
const currentPerspective = perspectives.find(p => p.id.toLowerCase() === currentRole.toLowerCase()) || perspectives[0];
|
||||||
const Icon = currentPerspective.icon;
|
const Icon = currentPerspective.icon;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,7 +158,7 @@ export const PerspectiveSwitcher: React.FC<{ currentRole: string; onSwitch: (rol
|
|||||||
</div>
|
</div>
|
||||||
{items.map((p) => {
|
{items.map((p) => {
|
||||||
const ItemIcon = p.icon;
|
const ItemIcon = p.icon;
|
||||||
const isActive = p.id === currentRole;
|
const isActive = p.id.toLowerCase() === currentRole.toLowerCase();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Zap,
|
Zap,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
MoreVertical,
|
||||||
|
Monitor
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { PerspectiveSwitcher } from './PerspectiveSwitcher';
|
import { PerspectiveSwitcher } from './PerspectiveSwitcher';
|
||||||
@@ -16,6 +18,8 @@ import { logout } from '../utils/auth';
|
|||||||
export const Sidebar: React.FC = () => {
|
export const Sidebar: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
const isFleetPage = location.pathname.startsWith('/fleet-operator');
|
||||||
|
|
||||||
// Safely parse user data
|
// Safely parse user data
|
||||||
const user = useMemo(() => {
|
const user = useMemo(() => {
|
||||||
@@ -24,8 +28,9 @@ export const Sidebar: React.FC = () => {
|
|||||||
if (!stored || stored === 'undefined' || stored === 'null') return { roles: [] };
|
if (!stored || stored === 'undefined' || stored === 'null') return { roles: [] };
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (parsed && typeof parsed === 'object') {
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
// Normalise roles to lowercase_underscore so "Fleet Operator" === "FLEET_OPERATOR"
|
||||||
parsed.roles = Array.isArray(parsed.roles)
|
parsed.roles = Array.isArray(parsed.roles)
|
||||||
? parsed.roles.map((r: any) => String(r).toUpperCase().replace(/\s+/g, '_'))
|
? parsed.roles.map((r: any) => String(r).toLowerCase().replace(/\s+/g, '_'))
|
||||||
: [];
|
: [];
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -41,76 +46,191 @@ export const Sidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRoleSwitch = (role: string) => {
|
const handleRoleSwitch = (role: string) => {
|
||||||
const updatedUser = { ...user, roles: [role.toUpperCase()] };
|
// Preserve CURESELECT_ADMIN so the admin doesn't lose access to the perspective switcher when switching roles
|
||||||
|
const originalStored = localStorage.getItem('teleems_user');
|
||||||
|
let originalRoles: string[] = [];
|
||||||
|
try {
|
||||||
|
if (originalStored) {
|
||||||
|
const parsed = JSON.parse(originalStored);
|
||||||
|
originalRoles = Array.isArray(parsed?.roles) ? parsed.roles : [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAdmin = originalRoles.some((r: string) => {
|
||||||
|
const normalized = r.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
return normalized === 'cureselect_admin' || normalized === 'admin';
|
||||||
|
});
|
||||||
|
|
||||||
|
let newRoles = [role.toUpperCase()];
|
||||||
|
if (hasAdmin && role.toUpperCase() !== 'CURESELECT_ADMIN') {
|
||||||
|
newRoles.push('CURESELECT_ADMIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = { ...user, roles: newRoles };
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(updatedUser));
|
localStorage.setItem('teleems_user', JSON.stringify(updatedUser));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentRole = user.roles?.[0] || 'CURESELECT_ADMIN';
|
const currentRole = user.roles?.[0] || 'cureselect_admin';
|
||||||
const displayName = String(user.username || (currentRole === 'CURESELECT_ADMIN' ? 'CureSelect Admin' : 'Admin User'));
|
const displayName = String(user.name || user.username || (currentRole === 'cureselect_admin' ? 'CureSelect Admin' : 'Fleet Operator'));
|
||||||
const displayId = user.id ? `#${String(user.id).substring(0, 6)}` : '#789022';
|
const displayId = user.id ? `#${String(user.id).substring(0, 6)}` : '#789022';
|
||||||
const initials = currentRole === 'CURESELECT_ADMIN' ? 'CA' : (displayName.substring(0, 2).toUpperCase() || 'AU');
|
const initials = (displayName.split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)) || 'FO';
|
||||||
|
|
||||||
const filteredNavItems = useMemo(() => {
|
const filteredNavItems = useMemo(() => {
|
||||||
const userRoles = Array.isArray(user.roles) ? user.roles : [];
|
if (isFleetPage) {
|
||||||
const adminRoles = ['CURESELECT_ADMIN', 'ADMIN', 'SUPER_ADMIN', 'SUPERADMIN'];
|
const fleetItems = NAVIGATION_CONFIG.filter(item =>
|
||||||
const hasAdminRole = userRoles.some((r: string) => adminRoles.includes(r));
|
item.id.startsWith('fleet-') || item.path.includes('/fleet-operator')
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAdmin = user.roles?.some((r: string) => {
|
||||||
|
const norm = r.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
return norm === 'cureselect_admin' || norm === 'admin';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'launcher',
|
||||||
|
label: 'Portal Hub',
|
||||||
|
icon: Monitor,
|
||||||
|
path: '/launcher',
|
||||||
|
roles: ['CURESELECT_ADMIN']
|
||||||
|
},
|
||||||
|
...fleetItems
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return fleetItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active perspective role being viewed (e.g. 'hospital_admin', 'fleet_operator', 'cureselect_admin')
|
||||||
|
const activeRole = currentRole.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
|
||||||
|
// Check if the active perspective is a platform-wide admin role
|
||||||
|
const isAdminPerspective = ['cureselect_admin', 'admin', 'super_admin', 'superadmin'].includes(activeRole);
|
||||||
|
|
||||||
const filterItems = (items: NavItem[]): NavItem[] => {
|
const filterItems = (items: NavItem[]): NavItem[] => {
|
||||||
return items.filter(item => {
|
return items.filter(item => {
|
||||||
const hasItemRole = item.roles.some(role => userRoles.includes(role.toUpperCase()));
|
// If viewing as CureSelect Admin, they can see everything (or we filter as admin)
|
||||||
return hasAdminRole || hasItemRole;
|
if (isAdminPerspective) return true;
|
||||||
|
|
||||||
|
// Otherwise, filter items so they must match the active role perspective
|
||||||
|
return item.roles.some(role =>
|
||||||
|
role.toLowerCase().replace(/\s+/g, '_') === activeRole
|
||||||
|
);
|
||||||
}).map(item => ({
|
}).map(item => ({
|
||||||
...item,
|
...item,
|
||||||
subItems: item.subItems ? filterItems(item.subItems) : undefined
|
subItems: item.subItems ? filterItems(item.subItems) : undefined
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = filterItems(NAVIGATION_CONFIG);
|
return filterItems(NAVIGATION_CONFIG);
|
||||||
console.log('[DEBUG] userRoles:', userRoles, 'filteredItems:', result);
|
}, [currentRole, isFleetPage, user.roles]);
|
||||||
return result;
|
|
||||||
}, [user.roles]);
|
|
||||||
|
|
||||||
const renderNavItem = (item: NavItem, isSubItem = false) => {
|
const renderNavItem = (item: NavItem, isSubItem = false) => {
|
||||||
const Icon = item.icon || AlertCircle;
|
const Icon = item.icon || AlertCircle;
|
||||||
const isActive = location.pathname === item.path || (item.path.includes('?') && location.pathname + location.search === item.path);
|
const currentUrl = location.pathname + location.search;
|
||||||
|
|
||||||
|
// Split path into pathname + search to handle query params correctly
|
||||||
|
const [itemPathname, itemSearch] = item.path.split('?');
|
||||||
|
const itemTo = itemSearch
|
||||||
|
? { pathname: itemPathname, search: `?${itemSearch}` }
|
||||||
|
: item.path;
|
||||||
|
|
||||||
|
const isActive = currentUrl === item.path ||
|
||||||
|
(itemSearch ? location.pathname === itemPathname && location.search === `?${itemSearch}` : location.pathname === item.path);
|
||||||
const hasSubItems = item.subItems && item.subItems.length > 0;
|
const hasSubItems = item.subItems && item.subItems.length > 0;
|
||||||
const isParentActive = hasSubItems && (isActive || item.subItems?.some(sub => location.pathname === sub.path.split('?')[0]));
|
const isParentActive = hasSubItems && (isActive || item.subItems?.some(sub => {
|
||||||
|
const [subPath, subSearch] = sub.path.split('?');
|
||||||
|
return subSearch
|
||||||
|
? location.pathname === subPath && location.search === `?${subSearch}`
|
||||||
|
: location.pathname === subPath;
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} style={{ display: 'flex', flexDirection: 'column' }}>
|
<div key={item.id} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.path}
|
to={itemTo}
|
||||||
className={({ isActive: linkActive }) =>
|
title={isSidebarCollapsed ? item.label : undefined}
|
||||||
`${isSubItem ? 'sidebar-sub-item' : 'sidebar-nav-item'} ${(linkActive || isActive) ? 'active' : ''}`
|
style={({ isActive: linkActive }) => {
|
||||||
}
|
const active = itemSearch ? isActive : linkActive;
|
||||||
|
return {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: isSidebarCollapsed ? 'center' : 'flex-start',
|
||||||
|
gap: isSidebarCollapsed ? '0' : '12px',
|
||||||
|
padding: isSidebarCollapsed ? '12px' : isSubItem ? '10px 20px 10px 48px' : '12px 16px',
|
||||||
|
margin: isSidebarCollapsed ? '4px 12px' : '2px 12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: active ? (isFleetPage ? '#06B6D4' : '#fff') : (isFleetPage ? '#475569' : '#94A3B8'),
|
||||||
|
background: active ? (isFleetPage ? 'linear-gradient(90deg, rgba(6, 182, 212, 0.08), rgba(59, 130, 246, 0.02))' : 'linear-gradient(90deg, rgba(6, 182, 212, 0.15), rgba(59, 130, 246, 0.05))') : 'transparent',
|
||||||
|
borderLeft: active && !isSubItem && !isSidebarCollapsed ? '3px solid #06B6D4' : '3px solid transparent',
|
||||||
|
boxShadow: active && isSidebarCollapsed ? '0 0 0 1px rgba(6,182,212,0.4)' : 'none',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
className={(navData) => `nav-item-link ${(itemSearch ? isActive : navData.isActive) ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
{!isSubItem && <Icon size={18} style={{ flexShrink: 0 }} />}
|
{({ isActive: linkActive }) => {
|
||||||
<span className="sidebar-label" style={{
|
const active = itemSearch ? isActive : linkActive;
|
||||||
flex: 1,
|
return (
|
||||||
|
<>
|
||||||
|
<Icon size={20} color={active ? '#06B6D4' : 'currentColor'} style={{ flexShrink: 0 }} />
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ flex: 1, display: 'flex', alignItems: 'center', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontWeight: (active || isParentActive) ? 700 : 500,
|
||||||
|
fontSize: isSubItem ? '0.8rem' : '0.875rem',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis'
|
textOverflow: 'ellipsis',
|
||||||
|
letterSpacing: '0.01em',
|
||||||
|
flex: 1
|
||||||
}}>
|
}}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{hasSubItems && (
|
{hasSubItems && (
|
||||||
<span className="sidebar-label">
|
<div style={{
|
||||||
{isParentActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
transform: isParentActive ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
</span>
|
transition: 'transform 0.3s ease',
|
||||||
|
color: '#94A3B8'
|
||||||
|
}}>
|
||||||
|
<ChevronRight size={16} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hasSubItems && isParentActive && (
|
{hasSubItems && isParentActive && !isSidebarCollapsed && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
style={{ overflow: 'hidden', background: 'transparent' }}
|
style={{ overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
|
<div style={{
|
||||||
|
borderLeft: isFleetPage ? '1px solid rgba(15, 23, 42, 0.08)' : '1px solid rgba(255,255,255,0.1)',
|
||||||
|
marginLeft: '28px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
paddingBottom: '8px'
|
||||||
|
}}>
|
||||||
{item.subItems?.map(sub => renderNavItem(sub, true))}
|
{item.subItems?.map(sub => renderNavItem(sub, true))}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -119,102 +239,153 @@ export const Sidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar-premium" style={{
|
<>
|
||||||
width: 'var(--sidebar-width)',
|
<style>{`
|
||||||
minWidth: 'var(--sidebar-width)',
|
.nav-item-link:not(.active):hover {
|
||||||
flexBasis: 'var(--sidebar-width)',
|
background: ${isFleetPage ? 'rgba(15, 23, 42, 0.03)' : 'rgba(255,255,255,0.03)'} !important;
|
||||||
flexShrink: 0,
|
color: ${isFleetPage ? '#0F172A' : '#fff'} !important;
|
||||||
height: '100vh',
|
transform: translateX(6px);
|
||||||
display: 'flex',
|
}
|
||||||
flexDirection: 'column',
|
`}</style>
|
||||||
zIndex: 1100,
|
<motion.aside
|
||||||
position: 'relative'
|
initial={{ width: 280 }}
|
||||||
}}>
|
animate={{ width: isSidebarCollapsed ? 80 : 280 }}
|
||||||
<div style={{
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
padding: '16px 20px',
|
|
||||||
borderBottom: '1px solid #edf2f7',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
background: 'linear-gradient(135deg, #0ea5e9, #3b82f6)',
|
|
||||||
borderRadius: '7px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<Zap size={16} color="#ffffff" />
|
|
||||||
</div>
|
|
||||||
<h2 className="sidebar-label sidebar-logo-text" style={{ fontSize: '1.1rem', margin: 0 }}>CureSelect</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '12px 0', overflowY: 'auto', minHeight: 0 }} className="no-scrollbar">
|
|
||||||
{filteredNavItems.map(item => renderNavItem(item))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderTop: '1px solid #edf2f7',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '10px',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{/* Perspective Switcher */}
|
|
||||||
{(user.roles?.includes('CURESELECT_ADMIN') || user.roles?.includes('ADMIN')) && (
|
|
||||||
<PerspectiveSwitcher
|
|
||||||
currentRole={currentRole}
|
|
||||||
onSwitch={handleRoleSwitch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User row */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0, overflow: 'hidden' }}>
|
|
||||||
<div style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '30px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: 'linear-gradient(135deg, var(--accent-cyan), #3b82f6)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#fff',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>{initials}</div>
|
|
||||||
<div className="sidebar-label" style={{ minWidth: 0, overflow: 'hidden' }}>
|
|
||||||
<div style={{ fontSize: '0.8rem', fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayName}</div>
|
|
||||||
<div style={{ fontSize: '0.68rem', color: '#94a3b8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>ID: {displayId}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
style={{
|
style={{
|
||||||
background: '#fef2f2',
|
background: isFleetPage ? '#FFFFFF' : '#040B16',
|
||||||
border: '1px solid #fecaca',
|
borderRight: isFleetPage ? '1px solid rgba(15, 23, 42, 0.08)' : '1px solid rgba(255,255,255,0.05)',
|
||||||
borderRadius: '7px',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
zIndex: 1100,
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: "'Inter', sans-serif"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Brand Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: isSidebarCollapsed ? '24px 16px' : '24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: isSidebarCollapsed ? 'center' : 'space-between',
|
||||||
|
borderBottom: isFleetPage ? '1px solid rgba(15, 23, 42, 0.08)' : '1px solid rgba(255,255,255,0.05)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => { if (isSidebarCollapsed) setIsSidebarCollapsed(false); }}
|
||||||
|
title={isSidebarCollapsed ? "Expand Menu" : undefined}
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
background: 'linear-gradient(135deg, #06B6D4, #3B82F6)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 0 20px rgba(6,182,212,0.4)',
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: isSidebarCollapsed ? 'pointer' : 'default'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Zap size={20} color="#FFFFFF" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<motion.div initial={{ opacity: 0, width: 0 }} animate={{ opacity: 1, width: 'auto' }} exit={{ opacity: 0, width: 0 }} style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 900, color: isFleetPage ? '#0F172A' : '#fff', margin: 0, letterSpacing: '-0.5px', lineHeight: 1.2 }}>CureSelect</h2>
|
||||||
|
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#06B6D4', textTransform: 'uppercase', letterSpacing: '2px' }}>Platform</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Minimize/Collapse button near CureSelect title */}
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarCollapsed(true)}
|
||||||
|
title="Collapse Menu"
|
||||||
|
style={{
|
||||||
|
background: isFleetPage ? 'rgba(15, 23, 42, 0.02)' : 'rgba(255,255,255,0.02)',
|
||||||
|
border: isFleetPage ? '1px solid rgba(15, 23, 42, 0.06)' : '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '8px',
|
||||||
padding: '6px',
|
padding: '6px',
|
||||||
color: '#ef4444',
|
color: isFleetPage ? '#475569' : '#94A3B8',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.2s'
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = isFleetPage ? 'rgba(15, 23, 42, 0.06)' : 'rgba(255,255,255,0.08)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isFleetPage ? 'rgba(15, 23, 42, 0.02)' : 'rgba(255,255,255,0.02)'}
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Area */}
|
||||||
|
<nav style={{ flex: 1, padding: '16px 0', overflowY: 'auto', minHeight: 0 }} className="no-scrollbar">
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} style={{ padding: '0 24px', marginBottom: '8px', fontSize: '0.65rem', fontWeight: 700, color: isFleetPage ? '#475569' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||||
|
{isFleetPage ? 'Fleet Command' : 'Main Menu'}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
{filteredNavItems.map(item => renderNavItem(item))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User Footer Profile */}
|
||||||
|
<div style={{ padding: '16px', borderTop: isFleetPage ? '1px solid rgba(15, 23, 42, 0.08)' : '1px solid rgba(255,255,255,0.05)', display: 'flex', flexDirection: 'column', gap: '12px', flexShrink: 0 }}>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isSidebarCollapsed && (
|
||||||
|
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }}>
|
||||||
|
{/* Perspective Switcher */}
|
||||||
|
{(user.roles?.includes('cureselect_admin') || user.roles?.includes('admin') || user.roles?.includes('CURESELECT_ADMIN') || user.roles?.includes('ADMIN')) && (
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<PerspectiveSwitcher
|
||||||
|
currentRole={currentRole}
|
||||||
|
onSwitch={handleRoleSwitch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Premium User Card */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px',
|
||||||
|
padding: '12px', background: isFleetPage ? 'rgba(6, 182, 212, 0.04)' : 'rgba(6, 182, 212, 0.05)', border: isFleetPage ? '1px solid rgba(6, 182, 212, 0.08)' : '1px solid rgba(6, 182, 212, 0.1)',
|
||||||
|
borderRadius: '12px', transition: 'all 0.3s ease', cursor: 'pointer'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '32px', height: '32px', borderRadius: '10px', background: isFleetPage ? '#FFFFFF' : '#040B16',
|
||||||
|
border: '1px solid #06B6D4', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '0.75rem', fontWeight: 800, color: '#06B6D4', flexShrink: 0
|
||||||
|
}}>{initials}</div>
|
||||||
|
<div style={{ minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', fontWeight: 700, color: isFleetPage ? '#0F172A' : '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayName}</div>
|
||||||
|
<div style={{ fontSize: '0.6rem', fontWeight: 600, color: '#06B6D4', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{currentRole.replace(/_/g, ' ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleLogout(); }}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: isFleetPage ? '#64748B' : '#94A3B8', cursor: 'pointer', padding: '4px', borderRadius: '6px', transition: 'all 0.2s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = isFleetPage ? '#64748B' : '#94A3B8'; e.currentTarget.style.background = 'transparent'; }}
|
||||||
title="Sign Out"
|
title="Sign Out"
|
||||||
>
|
>
|
||||||
<LogOut size={14} />
|
<LogOut size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</motion.aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
/* --- PROFESSIONAL RESPONSIVE TOPBAR --- */
|
|
||||||
.topbar-container {
|
|
||||||
height: var(--topbar-height);
|
|
||||||
margin: 12px 12px 0 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 16px;
|
|
||||||
gap: 16px;
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
z-index: 900;
|
|
||||||
border-radius: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-snappy);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn.home {
|
|
||||||
background: var(--accent-cyan-soft);
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover {
|
|
||||||
background: #f1f5f9;
|
|
||||||
border-color: #cbd5e1;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 1;
|
|
||||||
width: clamp(160px, 20vw, 280px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap input {
|
|
||||||
width: 100%;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 10px 7px 34px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
outline: none;
|
|
||||||
transition: var(--transition-snappy);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap input:focus {
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-cyan-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clock-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clock-time {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-bell {
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: color 0.15s;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-bell:hover { color: var(--text-primary); }
|
|
||||||
|
|
||||||
.bell-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0px;
|
|
||||||
right: -2px;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
background: var(--alert-red);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-profile {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 5px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: var(--transition-snappy);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-profile:hover {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-color: var(--card-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-circle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(135deg, var(--accent-cyan), #3b82f6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-role {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.nav-btn span { display: none; }
|
|
||||||
.clock-wrap { display: none; }
|
|
||||||
.search-wrap { width: 180px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.topbar-container { margin: 8px 8px 0; padding: 0 12px; }
|
|
||||||
.search-wrap { display: none; }
|
|
||||||
.user-info { display: none; }
|
|
||||||
.nav-actions { gap: 4px; }
|
|
||||||
.topbar-right { gap: 12px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.nav-actions { display: none; }
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Search, Bell, Clock, LogOut, Home, ArrowLeft } from 'lucide-react';
|
import { Search, Bell, Clock, LogOut, Home, ArrowLeft } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { logout } from '../utils/auth';
|
import { logout } from '../utils/auth';
|
||||||
import './TopBar.css';
|
|
||||||
|
|
||||||
export const TopBar: React.FC = () => {
|
export const TopBar: React.FC = () => {
|
||||||
const [time, setTime] = useState(new Date());
|
const [time, setTime] = useState(new Date());
|
||||||
@@ -30,87 +29,256 @@ export const TopBar: React.FC = () => {
|
|||||||
|
|
||||||
const displayName = String(user.username || 'Admin');
|
const displayName = String(user.username || 'Admin');
|
||||||
const rawRole = Array.isArray(user.roles) ? (user.roles[0] || 'Administrator') : 'Administrator';
|
const rawRole = Array.isArray(user.roles) ? (user.roles[0] || 'Administrator') : 'Administrator';
|
||||||
|
// Shorten long role names for the header
|
||||||
const roleLabel = rawRole
|
const roleLabel = rawRole
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
.replace('CURESELECT ADMIN', 'CS ADMIN')
|
.replace('CURESELECT ADMIN', 'CS ADMIN')
|
||||||
.replace('HOSPITAL ADMIN', 'H. ADMIN')
|
.replace('HOSPITAL ADMIN', 'H. ADMIN')
|
||||||
.replace('FLEET OPERATOR', 'FLEET OPS')
|
.replace('FLEET OPERATOR', 'FLEET OPS')
|
||||||
.replace('STATION INCHARGE', 'STATION IC');
|
.replace('STATION INCHARGE', 'STATION IC');
|
||||||
|
|
||||||
const initials = displayName.substring(0, 2).toUpperCase() || 'AD';
|
const initials = displayName.substring(0, 2).toUpperCase() || 'AD';
|
||||||
|
|
||||||
const formattedTime = time.toLocaleTimeString('en-US', {
|
const formattedTime = time.toLocaleTimeString('en-US', {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
timeZone: 'Asia/Kolkata',
|
timeZone: 'Asia/Kolkata',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
});
|
});
|
||||||
|
|
||||||
const tzLabel = 'IST';
|
const tzLabel = 'IST';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="topbar-container">
|
<header
|
||||||
<div className="topbar-left">
|
className="glass"
|
||||||
<div className="nav-actions">
|
style={{
|
||||||
|
height: 'var(--topbar-height)',
|
||||||
|
margin: '16px 16px 0 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 20px',
|
||||||
|
gap: '16px',
|
||||||
|
border: '1px solid var(--card-border)',
|
||||||
|
background: 'var(--glass-bg)',
|
||||||
|
zIndex: 900,
|
||||||
|
borderRadius: '12px',
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── LEFT: Search + status ────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
{/* Global Navigation Controls */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||||
|
{/* Back Navigation Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="nav-btn"
|
className="hover-glow"
|
||||||
title="Go Back"
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.03)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 14px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.8rem'
|
||||||
|
}}
|
||||||
|
title="Go to Previous Screen"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
<span>BACK</span>
|
<span>BACK</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Home Navigation Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
className="nav-btn home"
|
className="hover-glow"
|
||||||
title="Return Home"
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
background: 'rgba(0, 209, 255, 0.1)',
|
||||||
|
border: '1px solid rgba(0, 209, 255, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 14px',
|
||||||
|
color: 'var(--accent-cyan)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.8rem'
|
||||||
|
}}
|
||||||
|
title="Return to Dashboard"
|
||||||
>
|
>
|
||||||
<Home size={16} />
|
<Home size={16} />
|
||||||
<span>HOME</span>
|
<span>HOME</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="search-wrap">
|
{/* Search bar */}
|
||||||
<Search size={14} className="search-icon" style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', opacity: 0.5 }} />
|
<div style={{ position: 'relative', flexShrink: 0, width: 'clamp(160px, 22vw, 320px)' }}>
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)', pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search resources..."
|
placeholder="Search operators, hospitals, incidents..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: 'rgba(0, 0, 0, 0.03)',
|
||||||
|
border: '1px solid var(--card-border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 10px 8px 36px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
outline: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status pill — flexible, shrinks if needed */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '7px',
|
||||||
|
background: 'rgba(0, 255, 136, 0.08)',
|
||||||
|
padding: '5px 11px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: '1px solid rgba(0, 255, 136, 0.18)',
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
maxWidth: '260px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '7px', height: '7px', background: 'var(--accent-green)', borderRadius: '50%', boxShadow: '0 0 6px var(--accent-green)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.68rem', fontWeight: 700, color: 'var(--accent-green)', letterSpacing: '0.03em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
SYSTEM CONTROL PLANE HEALTHY
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="topbar-right">
|
{/* ── RIGHT: Clock + Bell + Profile ───────────────────────── */}
|
||||||
<div className="clock-wrap">
|
<div style={{ display: 'flex', alignItems: 'center', gap: '20px', flexShrink: 0 }}>
|
||||||
<Clock size={14} />
|
{/* Clock */}
|
||||||
<span className="clock-time">
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-secondary)', flexShrink: 0 }}>
|
||||||
{formattedTime} <small style={{ fontSize: '0.6rem', opacity: 0.6 }}>{tzLabel}</small>
|
<Clock size={15} />
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: '0.92rem', color: 'var(--text-primary)', fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{formattedTime}{' '}
|
||||||
|
<span style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>{tzLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="notification-bell">
|
{/* Bell */}
|
||||||
<Bell size={18} />
|
<div style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}>
|
||||||
<div className="bell-badge">3</div>
|
<Bell size={18} style={{ color: 'var(--text-secondary)' }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-4px',
|
||||||
|
right: '-5px',
|
||||||
|
width: '15px',
|
||||||
|
height: '15px',
|
||||||
|
background: 'var(--alert-red)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 0 8px var(--alert-red)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ height: '20px', width: '1px', background: 'var(--card-border)' }} />
|
{/* Divider */}
|
||||||
|
<div style={{ height: '20px', width: '1px', background: 'var(--card-border)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Profile */}
|
||||||
<div
|
<div
|
||||||
className="user-profile"
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
maxWidth: '220px',
|
||||||
|
}}
|
||||||
|
className="hover-glow"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
title="Click to logout"
|
title="Click to logout"
|
||||||
>
|
>
|
||||||
<div className="avatar-circle">
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '30px',
|
||||||
|
height: '30px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, var(--accent-cyan), #0066ff)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-info">
|
{/* Name + Role */}
|
||||||
<span className="user-name">{displayName}</span>
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||||
<span className="user-role">{roleLabel} • Logout</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'var(--accent-cyan)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roleLabel} • Logout
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LogOut size={14} style={{ opacity: 0.5 }} />
|
<LogOut size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ import {
|
|||||||
PhoneCall,
|
PhoneCall,
|
||||||
Navigation,
|
Navigation,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
LayoutGrid,
|
LayoutGrid
|
||||||
Video,
|
|
||||||
FileText,
|
|
||||||
TrendingUp,
|
|
||||||
BedDouble
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@@ -41,10 +37,10 @@ export const NAVIGATION_CONFIG: NavItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
label: 'Dashboard',
|
label: 'Admin Dashboard',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
path: '/',
|
path: '/',
|
||||||
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN']
|
roles: ['CURESELECT_ADMIN']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'incidents',
|
id: 'incidents',
|
||||||
@@ -60,21 +56,16 @@ export const NAVIGATION_CONFIG: NavItem[] = [
|
|||||||
path: '/caller',
|
path: '/caller',
|
||||||
roles: ['CURESELECT_ADMIN', 'CALLER']
|
roles: ['CURESELECT_ADMIN', 'CALLER']
|
||||||
},
|
},
|
||||||
{
|
{ id: 'fleet-overview', label: 'Live Dashboard', icon: LayoutGrid, path: '/fleet-operator?tab=overview', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
id: 'fleet-operator',
|
{ id: 'fleet-organization', label: 'Org & Stations', icon: Hospital, path: '/fleet-operator?tab=organization', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
label: 'Fleet Command',
|
|
||||||
icon: Zap,
|
|
||||||
path: '/fleet-operator',
|
|
||||||
roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'],
|
|
||||||
subItems: [
|
|
||||||
{ id: 'fleet-overview', label: 'Command Center', icon: LayoutGrid, path: '/fleet-operator?tab=overview', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
|
||||||
{ id: 'fleet-assets', label: 'Fleet Assets', icon: Truck, path: '/fleet-operator?tab=assets', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
{ id: 'fleet-assets', label: 'Fleet Assets', icon: Truck, path: '/fleet-operator?tab=assets', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{ id: 'fleet-personnel', label: 'Personnel Hub', icon: Users, path: '/fleet-operator?tab=personnel', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
{ id: 'fleet-personnel', label: 'Staf Management', icon: Users, path: '/fleet-operator?tab=personnel', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{ id: 'fleet-mission', label: 'Mission Control', icon: Navigation, path: '/fleet-operator?tab=scheduling', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
{ id: 'fleet-mission', label: 'Crew Scheduling', icon: Navigation, path: '/fleet-operator?tab=scheduling', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{ id: 'fleet-inventory', label: 'Supply Chain', icon: ShoppingCart, path: '/fleet-operator?tab=inventory', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
{ id: 'fleet-inventory', label: 'Inventory Management', icon: ShoppingCart, path: '/fleet-operator?tab=inventory', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{ id: 'fleet-intel', label: 'Fleet Intel', icon: Activity, path: '/fleet-operator?tab=analytics', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
{ id: 'fleet-trips', label: 'Trip Management', icon: Activity, path: '/fleet-operator?tab=trips', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
]
|
// { id: 'fleet-telematics', label: 'GPS Telematics', icon: Navigation, path: '/fleet-operator?tab=telematics', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
},
|
// { id: 'fleet-referrals', label: 'Referral Network', icon: HeartPulse, path: '/fleet-operator?tab=referrals', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
|
{ id: 'fleet-intel', label: 'Fleet Analytics', icon: PieChart, path: '/fleet-operator?tab=analytics', roles: ['CURESELECT_ADMIN', 'FLEET_OPERATOR'] },
|
||||||
{
|
{
|
||||||
id: 'clinical',
|
id: 'clinical',
|
||||||
label: 'Clinical Intelligence',
|
label: 'Clinical Intelligence',
|
||||||
@@ -94,19 +85,7 @@ export const NAVIGATION_CONFIG: NavItem[] = [
|
|||||||
label: 'Hospital Ops',
|
label: 'Hospital Ops',
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
path: '/hospital-console',
|
path: '/hospital-console',
|
||||||
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'],
|
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT']
|
||||||
subItems: [
|
|
||||||
{ id: 'hosp-ed', label: 'ED Monitor', icon: Monitor, path: '/hospital-console?tab=ED_MONITOR', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-admissions', label: 'Admissions', icon: BedDouble, path: '/hospital-console?tab=ADMISSIONS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-bookings', label: 'Trip Management', icon: Activity, path: '/hospital-console?tab=BOOKINGS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-fleet', label: 'Fleet Visibility', icon: Truck, path: '/hospital-console?tab=FLEET', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-telelink', label: 'TeleLink Hub', icon: Video, path: '/hospital-console?tab=TELELINK', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-epcr', label: 'ePCR Records', icon: FileText, path: '/hospital-console?tab=EPCR', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-history', label: 'Patient Archive', icon: Database, path: '/hospital-console?tab=HISTORY', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-reports', label: 'Analytics', icon: TrendingUp, path: '/hospital-console?tab=REPORTS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-referrals', label: 'Referral Hub', icon: Hospital, path: '/hospital-console?tab=REFERRALS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
{ id: 'hosp-setup', label: 'Account & Setup', icon: Settings, path: '/hospital-console?tab=SETUP', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'master-data',
|
id: 'master-data',
|
||||||
|
|||||||
344
src/index.css
344
src/index.css
@@ -1,62 +1,19 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* --- HSL DESIGN TOKENS (PROFESSIONAL MEDICAL PALETTE) --- */
|
--base-bg: #F8FAFC;
|
||||||
--hull-h: 220;
|
--card-bg: #FFFFFF;
|
||||||
--hull-s: 16%;
|
--card-border: rgba(59, 130, 246, 0.15);
|
||||||
--hull-l: 97%;
|
--accent-cyan: #3B82F6;
|
||||||
|
--accent-green: #10B981;
|
||||||
--hull-dark-h: 222;
|
--alert-red: #EF4444;
|
||||||
--hull-dark-s: 47%;
|
--warning-amber: #F59E0B;
|
||||||
--hull-dark-l: 11%;
|
--text-primary: #1E293B;
|
||||||
|
--text-secondary: #64748B;
|
||||||
/* Accents */
|
--glass-bg: rgba(255, 255, 255, 0.8);
|
||||||
--accent-cyan-h: 199;
|
--glass-blur: blur(12px);
|
||||||
--accent-cyan-s: 89%;
|
|
||||||
--accent-cyan-l: 48%;
|
|
||||||
|
|
||||||
--accent-purple-h: 262;
|
|
||||||
--accent-purple-s: 83%;
|
|
||||||
--accent-purple-l: 58%;
|
|
||||||
|
|
||||||
--accent-green-h: 160;
|
|
||||||
--accent-green-s: 84%;
|
|
||||||
--accent-green-l: 39%;
|
|
||||||
|
|
||||||
/* Semantic Mappings */
|
|
||||||
--base-bg: #f8f9fb;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--card-border: #e5e7eb;
|
|
||||||
--glass-bg: rgba(255, 255, 255, 0.6);
|
|
||||||
|
|
||||||
--accent-cyan: hsl(var(--accent-cyan-h), var(--accent-cyan-s), var(--accent-cyan-l));
|
|
||||||
--accent-cyan-soft: hsla(var(--accent-cyan-h), var(--accent-cyan-s), var(--accent-cyan-l), 0.07);
|
|
||||||
--accent-cyan-glow: hsla(var(--accent-cyan-h), var(--accent-cyan-s), var(--accent-cyan-l), 0.25);
|
|
||||||
|
|
||||||
--accent-purple: hsl(var(--accent-purple-h), var(--accent-purple-s), var(--accent-purple-l));
|
|
||||||
--accent-purple-soft: hsla(var(--accent-purple-h), var(--accent-purple-s), var(--accent-purple-l), 0.08);
|
|
||||||
|
|
||||||
--accent-green: hsl(var(--accent-green-h), var(--accent-green-s), var(--accent-green-l));
|
|
||||||
--accent-green-soft: hsla(var(--accent-green-h), var(--accent-green-s), var(--accent-green-l), 0.08);
|
|
||||||
|
|
||||||
--alert-red: hsl(4, 90%, 58%);
|
|
||||||
--warning-amber: hsl(38, 92%, 50%);
|
|
||||||
|
|
||||||
--text-primary: #1e293b;
|
|
||||||
--text-secondary: #64748b;
|
|
||||||
--text-muted: #94a3b8;
|
|
||||||
|
|
||||||
/* Effects */
|
|
||||||
--hull-glass: rgba(255, 255, 255, 0.85);
|
|
||||||
--glass-blur: blur(16px);
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
|
|
||||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.08);
|
|
||||||
--shadow-premium: 0 20px 40px -12px rgba(0, 0, 0, 0.08);
|
|
||||||
--transition-snappy: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
--sidebar-width: 260px;
|
--sidebar-width: 260px;
|
||||||
--topbar-height: 64px;
|
--topbar-height: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -66,14 +23,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
background-color: var(--base-bg);
|
background-color: var(--base-bg);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
@@ -148,17 +107,10 @@ h1, h2, h3, h4 {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4K UI Limits Wrapper */
|
/* Grid Layouts */
|
||||||
.page-content-wrapper {
|
|
||||||
max-width: 1920px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Grid Layouts */
|
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(6, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
@@ -167,49 +119,13 @@ h1, h2, h3, h4 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1.5fr 1fr;
|
grid-template-columns: 1fr 1.5fr 1fr;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
height: auto;
|
height: calc(100% - 140px);
|
||||||
min-height: calc(100% - 140px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-grid > * {
|
.main-grid > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Breakpoints */
|
|
||||||
@media (max-width: 1400px) {
|
|
||||||
.main-grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
.main-grid > *:last-child {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
:root {
|
|
||||||
--sidebar-width: 72px;
|
|
||||||
}
|
|
||||||
.sidebar-label {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.main-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.main-grid > *:last-child {
|
|
||||||
grid-column: span 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.page-container {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.stats-bar {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pulse Animation */
|
/* Pulse Animation */
|
||||||
@keyframes pulse-red {
|
@keyframes pulse-red {
|
||||||
0% { transform: scale(1); opacity: 1; }
|
0% { transform: scale(1); opacity: 1; }
|
||||||
@@ -334,221 +250,3 @@ select, select option {
|
|||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
|
||||||
/* ─── PREMIUM ADMIN DASHBOARD STYLES ─── */
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
.dashboard-header-premium {
|
|
||||||
position: relative;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: linear-gradient(135deg, hsla(210, 40%, 98%, 0.8), hsla(0, 0%, 100%, 0.95));
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
box-shadow: 0 4px 24px -6px rgba(0, 0, 0, 0.03);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-header-premium h2 {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
background: linear-gradient(to right, var(--text-primary), var(--accent-cyan));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-stat-card {
|
|
||||||
position: relative;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-stat-card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
|
|
||||||
border-color: rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-stat-icon-wrap {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-stat-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 750;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-stat-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 850;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-stat-sub {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-primary-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
gap: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-secondary-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-health-card {
|
|
||||||
background: hsla(210, 40%, 98%, 0.8);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-health-card:hover {
|
|
||||||
background: #fff;
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-status-badge {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.dashboard-primary-grid, .dashboard-secondary-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* ==========================================================================
|
|
||||||
GLOBAL SIDEBAR (PROFESSIONAL LIGHT DESIGN)
|
|
||||||
========================================================================== */
|
|
||||||
.sidebar-premium {
|
|
||||||
background: #ffffff !important;
|
|
||||||
border-right: 1px solid #edf2f7 !important;
|
|
||||||
box-shadow: 1px 0 8px rgba(0, 0, 0, 0.03);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logo-text {
|
|
||||||
background: linear-gradient(135deg, #0ea5e9, #2563eb);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
margin: 2px 12px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #475569;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
border-left: none;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav-item:hover {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav-item.active {
|
|
||||||
background: var(--accent-cyan-soft);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
font-weight: 600;
|
|
||||||
border-left: 3px solid var(--accent-cyan);
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav-item.active::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-sub-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 20px 8px 48px;
|
|
||||||
margin: 1px 12px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.825rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #64748b;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
border: none;
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-sub-item:hover {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-sub-item.active {
|
|
||||||
background: var(--accent-cyan-soft);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
font-weight: 600;
|
|
||||||
border-left: 3px solid var(--accent-cyan);
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
margin-left: 0;
|
|
||||||
padding-left: 45px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-sub-item.active::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<App />,
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -285,54 +285,6 @@ const CustomChartTooltip = ({ active, payload }: any) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- PREMIUM COMPONENTS ---
|
|
||||||
const PremiumStatCard: React.FC<{
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
subValue?: string;
|
|
||||||
icon: any;
|
|
||||||
trend?: { value: string; isUp: boolean };
|
|
||||||
glowColor: 'cyan' | 'green' | 'red' | 'amber';
|
|
||||||
pulse?: boolean;
|
|
||||||
}> = ({ label, value, subValue, icon: Icon, trend, glowColor, pulse }) => {
|
|
||||||
const colors = {
|
|
||||||
cyan: { bg: 'rgba(59, 130, 246, 0.1)', text: 'var(--accent-cyan)' },
|
|
||||||
green: { bg: 'rgba(16, 185, 129, 0.1)', text: 'var(--accent-green)' },
|
|
||||||
red: { bg: 'rgba(239, 68, 68, 0.1)', text: 'var(--alert-red)' },
|
|
||||||
amber: { bg: 'rgba(245, 158, 11, 0.1)', text: 'var(--warning-amber)' },
|
|
||||||
};
|
|
||||||
const theme = colors[glowColor];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="premium-stat-card">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
||||||
<div className="premium-stat-icon-wrap" style={{ background: theme.bg, color: theme.text }}>
|
|
||||||
<Icon size={22} />
|
|
||||||
</div>
|
|
||||||
{pulse && <div className="status-pulse" style={{ background: theme.text }}></div>}
|
|
||||||
</div>
|
|
||||||
<div className="premium-stat-label">{label}</div>
|
|
||||||
<div className="premium-stat-value">
|
|
||||||
{value}
|
|
||||||
{subValue && <span className="premium-stat-sub">/ {subValue}</span>}
|
|
||||||
</div>
|
|
||||||
{trend && (
|
|
||||||
<div style={{
|
|
||||||
marginTop: '12px',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: trend.isUp ? 'var(--accent-green)' : 'var(--alert-red)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px'
|
|
||||||
}}>
|
|
||||||
{trend.isUp ? '↑' : '↓'} {trend.value} <span style={{ color: 'var(--text-secondary)' }}>vs last hour</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dashboard: React.FC = () => {
|
export const Dashboard: React.FC = () => {
|
||||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
@@ -354,8 +306,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
const token = localStorage.getItem('teleems_token') || '';
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
const roles = Array.isArray(user.roles) ? user.roles : [];
|
const roles = Array.isArray(user.roles) ? user.roles : [];
|
||||||
const isFleetOp = roles.includes('FLEET_OPERATOR');
|
const isFleetOp = roles.includes('FLEET_OPERATOR');
|
||||||
const isHospitalAdmin = roles.some((r: string) => r.toUpperCase() === 'HOSPITAL_ADMIN' || r.toUpperCase() === 'HOSPITAL ADMIN');
|
const orgName = user.metadata?.organization?.company_name || 'Fleet Operator';
|
||||||
const orgName = user.metadata?.organization?.company_name || (isHospitalAdmin ? 'Hospital' : 'Fleet Operator');
|
|
||||||
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
const [clickCoords, setClickCoords] = useState<{ lat: number, lng: number } | null>(null);
|
const [clickCoords, setClickCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||||
@@ -467,34 +418,34 @@ export const Dashboard: React.FC = () => {
|
|||||||
})) : [];
|
})) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '0', paddingBottom: '40px' }}>
|
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '24px', paddingBottom: '40px' }}>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="dashboard-header-premium">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
<h2>
|
<h2 style={{ fontSize: '1.8rem', fontWeight: 800, background: 'linear-gradient(to right, var(--text-primary), var(--accent-cyan))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||||
{isFleetOp ? `${orgName} Command` : isHospitalAdmin ? `${orgName} Administration` : 'Super Admin Command Center'}
|
{isFleetOp ? `${orgName} Command` : 'Super Admin Command Center'}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
|
||||||
<span className="status-pulse" style={{ background: 'var(--accent-green)' }}></span>
|
<span className="status-pulse" style={{ background: 'var(--accent-green)' }}></span>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', fontWeight: 600 }}>
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
Live platform telemetry synchronized at {lastUpdated.toLocaleTimeString()}
|
Live platform telemetry synchronized at {lastUpdated.toLocaleTimeString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
<button onClick={fetchData} className="glass hover-glow" style={{ padding: '10px 20px', borderRadius: '8px', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', background: 'rgba(59,130,246,0.04)', color: 'var(--accent-cyan)', fontWeight: 700, fontSize: '0.8rem', border: '1px solid rgba(59,130,246,0.1)' }}>
|
<button onClick={fetchData} className="glass hover-glow" style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', background: 'rgba(59,130,246,0.02)', color: 'var(--accent-cyan)', fontWeight: 600, fontSize: '0.8rem' }}>
|
||||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} /> REFRESH LIVE
|
<RefreshCw size={14} className={isLoading ? 'spin' : ''} /> REFRESH LIVE
|
||||||
</button>
|
</button>
|
||||||
<div className="glass mono" style={{ padding: '10px 20px', borderRadius: '8px', fontSize: '0.8rem', color: 'var(--accent-green)', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 700, border: '1px solid rgba(16,185,129,0.1)' }}>
|
<div className="glass mono" style={{ padding: '8px 16px', fontSize: '0.75rem', color: 'var(--accent-green)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<UsersIcon size={16} /> {fleetOperators.length} OPERATORS ACTIVE
|
<UsersIcon size={14} /> {fleetOperators.length} OPERATORS ACTIVE
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary Stats Bar */}
|
{/* Primary Stats Bar */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '20px', marginBottom: '24px' }}>
|
<div className="stats-bar" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', marginBottom: 0 }}>
|
||||||
<PremiumStatCard
|
<StatCard
|
||||||
label="Active Incidents"
|
label="Active Incidents"
|
||||||
value={activeIncidents.length}
|
value={activeIncidents.length}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
@@ -502,73 +453,110 @@ export const Dashboard: React.FC = () => {
|
|||||||
pulse={activeIncidents.length > 0}
|
pulse={activeIncidents.length > 0}
|
||||||
trend={{ value: '14%', isUp: true }}
|
trend={{ value: '14%', isUp: true }}
|
||||||
/>
|
/>
|
||||||
<PremiumStatCard
|
<StatCard
|
||||||
label="Operational Fleet"
|
label="Operational Fleet"
|
||||||
value={fleetOperators.length}
|
value={fleetOperators.length}
|
||||||
subValue={users.length.toString()}
|
subValue={users.length.toString()}
|
||||||
icon={Truck}
|
icon={Truck}
|
||||||
glowColor="cyan"
|
glowColor="cyan"
|
||||||
/>
|
/>
|
||||||
<PremiumStatCard
|
<StatCard
|
||||||
label="Dispatch SLA"
|
label="Dispatch SLA"
|
||||||
value="1.4s"
|
value="1.4s"
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
glowColor="green"
|
glowColor="green"
|
||||||
trend={{ value: '0.2s', isUp: false }}
|
trend={{ value: '0.2s', isUp: false }}
|
||||||
/>
|
/>
|
||||||
<PremiumStatCard
|
<StatCard
|
||||||
label="Critical Cases"
|
label="Critical Cases"
|
||||||
value={criticalIssues.length}
|
value={criticalIssues.length}
|
||||||
icon={HeartPulse}
|
icon={HeartPulse}
|
||||||
glowColor="amber"
|
glowColor="amber"
|
||||||
/>
|
/>
|
||||||
<PremiumStatCard
|
<StatCard
|
||||||
label="Live CCE nodes"
|
label="Live CCE nodes"
|
||||||
value={users.filter(u => u.roles?.includes('CCE')).length || 4}
|
value={users.filter(u => u.roles?.includes('CCE')).length || 4}
|
||||||
icon={Video}
|
icon={Video}
|
||||||
glowColor="cyan"
|
glowColor="cyan"
|
||||||
/>
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Node Integrity"
|
||||||
|
value="100%"
|
||||||
|
icon={ShieldCheck}
|
||||||
|
glowColor="green"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Operational Grid: 2 Columns */}
|
{/* Main Operational Grid */}
|
||||||
<div className="dashboard-primary-grid">
|
<div className="main-grid" style={{ gridTemplateColumns: '1.5fr 1fr 1fr', height: 'auto', alignItems: 'start' }}>
|
||||||
{/* Real-time Heatmap */}
|
{/* Real-time Heatmap */}
|
||||||
<Card title="Global Incident Explorer" subtitle="System-wide incident history and live telemetry" className="premium-health-card" style={{ padding: 0 }}>
|
<Card title="Global Incident Explorer" subtitle="System-wide incident history and live telemetry">
|
||||||
<div style={{ padding: '24px', paddingBottom: 0 }}>
|
|
||||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 800 }}>Global Incident Explorer</h3>
|
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginBottom: '16px' }}>System-wide incident history and live telemetry</p>
|
|
||||||
</div>
|
|
||||||
<LiveIncidentMap incidents={incidents} />
|
<LiveIncidentMap incidents={incidents} />
|
||||||
|
|
||||||
<div style={{ position: 'absolute', bottom: '24px', right: '24px', zIndex: 1000, background: 'rgba(255,255,255,0.9)', padding: '12px 16px', borderRadius: '12px', border: '1px solid var(--card-border)', fontSize: '0.75rem', backdropFilter: 'blur(10px)', boxShadow: '0 8px 32px rgba(0,0,0,0.08)' }}>
|
{/* Legend Overlay (Absolute in Card) */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px' }}>
|
<div style={{ position: 'absolute', bottom: '32px', right: '32px', zIndex: 1000, background: 'rgba(255,255,255,0.85)', padding: '12px', borderRadius: '10px', border: '1px solid var(--card-border)', fontSize: '0.7rem', backdropFilter: 'blur(10px)', boxShadow: '0 8px 32px rgba(0,0,0,0.05)', pointerEvents: 'none' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 10px #EF4444' }}></div>
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 10px #EF4444' }}></div>
|
||||||
<span style={{ fontWeight: 750, letterSpacing: '0.05em', color: 'var(--text-primary)' }}>CRITICAL</span>
|
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>CRITICAL</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#F59E0B', boxShadow: '0 0 10px #F59E0B' }}></div>
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#F59E0B', boxShadow: '0 0 10px #F59E0B' }}></div>
|
||||||
<span style={{ fontWeight: 750, letterSpacing: '0.05em', color: 'var(--text-primary)' }}>ACTIVE (L/M)</span>
|
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>ACTIVE (L/M)</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#10B981', boxShadow: '0 0 10px #10B981' }}></div>
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#10B981', boxShadow: '0 0 10px #10B981' }}></div>
|
||||||
<span style={{ fontWeight: 750, letterSpacing: '0.05em', color: 'var(--text-primary)' }}>RESOLVED</span>
|
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>RESOLVED</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Governance Feed */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
|
<Card title="Governance Feed" subtitle="Real-time dispatch audit trail" style={{ height: '450px', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{incidents.slice(0, 8).map((inc) => (
|
||||||
|
<div key={inc.id} className="hover-glow" style={{
|
||||||
|
padding: '14px',
|
||||||
|
background: 'rgba(0,0,0,0.01)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
borderLeft: `4px solid ${inc.severity === 'CRITICAL' ? 'var(--alert-red)' : inc.severity === 'HIGH' ? 'var(--warning-amber)' : 'var(--accent-cyan)'}`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span className="mono" style={{ fontWeight: 800, fontSize: '0.85rem', color: 'var(--accent-cyan)' }}>{inc.id.split('-').pop()}</span>
|
||||||
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{inc.address}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', marginTop: '4px', textTransform: 'uppercase', fontWeight: 600 }}>{inc.status}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', fontWeight: 800 }}>{inc.eta_seconds ? `${Math.floor(inc.eta_seconds / 60)}m` : '--'}</div>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)' }}>ETA</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{incidents.length === 0 && <div style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '40px 0' }}>No active incidents</div>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Global Distribution & Health */}
|
{/* Global Distribution & Health */}
|
||||||
<Card title="Fleet Distribution" subtitle="Real-time system asset availability" className="premium-health-card">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', minHeight: 0 }}>
|
||||||
|
<Card title="Fleet Distribution" subtitle="Real-time system asset availability">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', justifyContent: 'space-between', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', justifyContent: 'space-between', minHeight: 0 }}>
|
||||||
<div style={{ height: '220px', position: 'relative', margin: '20px 0', minWidth: 0 }}>
|
<div style={{ height: '180px', position: 'relative', margin: '10px 0', minWidth: 0 }}>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height="180">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={fleetStatusData}
|
data={fleetStatusData}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={70}
|
innerRadius={60}
|
||||||
outerRadius={95}
|
outerRadius={80}
|
||||||
paddingAngle={6}
|
paddingAngle={8}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke="none"
|
stroke="none"
|
||||||
animationBegin={0}
|
animationBegin={0}
|
||||||
@@ -578,7 +566,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={entry.color}
|
fill={entry.color}
|
||||||
style={{ filter: `drop-shadow(0 0 12px ${entry.color}33)` }}
|
style={{ filter: `drop-shadow(0 0 8px ${entry.color}44)` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
@@ -595,71 +583,35 @@ export const Dashboard: React.FC = () => {
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
pointerEvents: 'none'
|
pointerEvents: 'none'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.1em' }}>Total</div>
|
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em' }}>Total</div>
|
||||||
<div style={{ fontSize: '2.5rem', fontWeight: 900, color: 'var(--text-primary)', lineHeight: 1, margin: '4px 0' }}>
|
<div style={{ fontSize: '1.6rem', fontWeight: 900, color: 'var(--text-primary)', lineHeight: 1 }}>
|
||||||
{fleetStatusData.reduce((acc, curr) => acc + curr.value, 0)}
|
{fleetStatusData.reduce((acc, curr) => acc + curr.value, 0)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.6rem', color: 'var(--accent-cyan)', fontWeight: 850 }}>ASSETS</div>
|
<div style={{ fontSize: '0.5rem', color: 'var(--accent-cyan)', fontWeight: 800, marginTop: '2px' }}>ASSETS</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', padding: '16px', background: 'rgba(0,0,0,0.02)', borderRadius: '12px', border: '1px solid rgba(0,0,0,0.05)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', padding: '12px', background: 'rgba(0,0,0,0.01)', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||||
{fleetStatusData.map(item => {
|
{fleetStatusData.map(item => {
|
||||||
const total = fleetStatusData.reduce((acc, curr) => acc + curr.value, 0);
|
const total = fleetStatusData.reduce((acc, curr) => acc + curr.value, 0);
|
||||||
const percentage = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
const percentage = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div key={item.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px' }}>
|
<div key={item.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 8px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<div style={{ width: '10px', height: '10px', background: item.color, borderRadius: '3px', boxShadow: `0 0 12px ${item.color}66` }}></div>
|
<div style={{ width: '8px', height: '8px', background: item.color, borderRadius: '2px', boxShadow: `0 0 10px ${item.color}66` }}></div>
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 750, color: 'var(--text-secondary)' }}>{item.name}</span>
|
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: 'var(--text-secondary)' }}>{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="mono" style={{ fontSize: '0.85rem', fontWeight: 850, color: 'var(--text-primary)' }}>{percentage}%</span>
|
<span className="mono" style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-primary)' }}>{percentage}%</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Secondary Grid: Governance Feed and Performance */}
|
<Card title="System Performance" subtitle="Transaction density (30s avg)">
|
||||||
<div className="dashboard-secondary-grid">
|
<div style={{ height: '140px', minWidth: 0, position: 'relative' }}>
|
||||||
{/* Governance Feed */}
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
<Card title="Governance Feed" subtitle="Real-time dispatch audit trail" className="premium-health-card" style={{ height: '400px', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '16px' }}>
|
|
||||||
{incidents.slice(0, 8).map((inc) => (
|
|
||||||
<div key={inc.id} className="hover-glow" style={{
|
|
||||||
padding: '16px',
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: '1px solid var(--card-border)',
|
|
||||||
borderLeft: `4px solid ${inc.severity === 'CRITICAL' ? 'var(--alert-red)' : inc.severity === 'HIGH' ? 'var(--warning-amber)' : 'var(--accent-cyan)'}`,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.02)'
|
|
||||||
}}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
||||||
<span className="mono" style={{ fontWeight: 850, fontSize: '0.9rem', color: 'var(--text-primary)' }}>#{inc.id.split('-').pop()}</span>
|
|
||||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{inc.address}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', marginTop: '6px', textTransform: 'uppercase', fontWeight: 750, color: 'var(--accent-cyan)' }}>{inc.status}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
|
||||||
<div style={{ fontSize: '0.9rem', fontWeight: 850, color: 'var(--text-primary)' }}>{inc.eta_seconds ? `${Math.floor(inc.eta_seconds / 60)}m` : '--'}</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 700 }}>ETA</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{incidents.length === 0 && <div style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '40px 0', fontWeight: 600 }}>No active incidents</div>}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="System Performance" subtitle="Transaction density (30s avg)" className="premium-health-card" style={{ height: '400px', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0, position: 'relative', marginTop: '16px' }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={activityData}>
|
<AreaChart data={activityData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorSessions" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorSessions" x1="0" y1="0" x2="0" y2="1">
|
||||||
@@ -670,89 +622,78 @@ export const Dashboard: React.FC = () => {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '8px', fontSize: '12px' }}
|
contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '8px', fontSize: '12px' }}
|
||||||
/>
|
/>
|
||||||
<Area type="monotone" dataKey="count" stroke="var(--accent-cyan)" strokeWidth={3} fillOpacity={1} fill="url(#colorSessions)" />
|
<Area type="monotone" dataKey="count" stroke="var(--accent-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorSessions)" />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Platform DNA Section */}
|
{/* Platform DNA Section */}
|
||||||
<section className="dashboard-primary-grid" style={{ marginBottom: '24px' }}>
|
<section style={{ display: 'grid', gridTemplateColumns: '2.5fr 1fr', gap: '24px' }}>
|
||||||
<Card title="Platform Architecture & Compliance" subtitle="Global oversight of system DNA, security flags and ABDM synchronization." className="premium-health-card" style={{ paddingBottom: '16px' }}>
|
<Card title="Platform Architecture & Compliance" subtitle="Global oversight of system DNA, security flags and ABDM synchronization.">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginTop: '16px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginTop: '8px' }}>
|
||||||
<div style={{ padding: '20px', borderRadius: '12px', background: '#0B1120', border: '1px solid #1E293B', color: '#fff', position: 'relative', overflow: 'hidden' }}>
|
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, var(--accent-cyan), transparent)' }}></div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Database size={20} color="var(--accent-cyan)" />
|
<Database size={22} color="var(--accent-cyan)" />
|
||||||
<span className="health-status-badge" style={{ background: 'rgba(16,185,129,0.15)', color: 'var(--accent-green)' }}>
|
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-green)' }}>SYNCED</span>
|
||||||
<span className="status-pulse" style={{ background: 'var(--accent-green)', width: '6px', height: '6px' }}></span> SYNCED
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1rem', fontWeight: 850, marginTop: '16px', letterSpacing: '-0.02em' }}>Master Data</div>
|
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Master Data</div>
|
||||||
<p style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '4px' }}>482 Triage rules active.</p>
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>482 Triage rules active.</p>
|
||||||
<div style={{ height: '1px', background: '#1E293B', margin: '16px 0' }}></div>
|
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||||
<button className="mono" style={{ width: '100%', padding: '10px', background: 'rgba(59,130,246,0.1)', border: '1px solid rgba(59,130,246,0.2)', color: '#60A5FA', fontSize: '0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 750, transition: 'all 0.2s' }}>MANAGE DNA</button>
|
<button className="mono" style={{ width: '100%', padding: '8px', background: 'rgba(59,130,246,0.1)', border: 'none', color: 'var(--accent-cyan)', fontSize: '0.6rem', borderRadius: '4px', cursor: 'pointer', fontWeight: 700 }}>MANAGE DNA</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '20px', borderRadius: '12px', background: '#0B1120', border: '1px solid #1E293B', color: '#fff', position: 'relative', overflow: 'hidden' }}>
|
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, var(--accent-green), transparent)' }}></div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<ShieldCheck size={20} color="var(--accent-green)" />
|
<ShieldCheck size={22} color="var(--accent-green)" />
|
||||||
<span className="health-status-badge" style={{ background: 'rgba(16,185,129,0.15)', color: 'var(--accent-green)' }}>
|
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-green)' }}>100%</span>
|
||||||
<span className="status-pulse" style={{ background: 'var(--accent-green)', width: '6px', height: '6px' }}></span> 100%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1rem', fontWeight: 850, marginTop: '16px', letterSpacing: '-0.02em' }}>Compliance</div>
|
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Compliance</div>
|
||||||
<p style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '4px' }}>HIPAA / ABDM verified.</p>
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>HIPAA / ABDM verified.</p>
|
||||||
<div style={{ height: '1px', background: '#1E293B', margin: '16px 0' }}></div>
|
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||||
<button className="mono" style={{ width: '100%', padding: '10px', background: 'rgba(16,185,129,0.1)', border: '1px solid rgba(16,185,129,0.2)', color: '#34D399', fontSize: '0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 750, transition: 'all 0.2s' }}>AUDIT LOGS</button>
|
<button className="mono" style={{ width: '100%', padding: '8px', background: 'rgba(16,185,129,0.1)', border: 'none', color: 'var(--accent-green)', fontSize: '0.6rem', borderRadius: '4px', cursor: 'pointer', fontWeight: 700 }}>AUDIT LOGS</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '20px', borderRadius: '12px', background: '#0B1120', border: '1px solid #1E293B', color: '#fff', position: 'relative', overflow: 'hidden' }}>
|
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, var(--warning-amber), transparent)' }}></div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Settings size={20} color="var(--warning-amber)" />
|
<Settings size={22} color="var(--warning-amber)" />
|
||||||
<span className="health-status-badge" style={{ background: 'rgba(245,158,11,0.15)', color: 'var(--warning-amber)' }}>
|
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--warning-amber)' }}>STABLE</span>
|
||||||
<span className="status-pulse" style={{ background: 'var(--warning-amber)', width: '6px', height: '6px' }}></span> STABLE
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1rem', fontWeight: 850, marginTop: '16px', letterSpacing: '-0.02em' }}>System Logic</div>
|
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>System Logic</div>
|
||||||
<p style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '4px' }}>SLA thresholds active.</p>
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>SLA thresholds active.</p>
|
||||||
<div style={{ height: '1px', background: '#1E293B', margin: '16px 0' }}></div>
|
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||||
<button className="mono" style={{ width: '100%', padding: '10px', background: 'rgba(245,158,11,0.1)', border: '1px solid rgba(245,158,11,0.2)', color: '#FBBF24', fontSize: '0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 750, transition: 'all 0.2s' }}>CONFIGURE</button>
|
<button className="mono" style={{ width: '100%', padding: '8px', background: 'rgba(245,158,0,0.1)', border: 'none', color: 'var(--warning-amber)', fontSize: '0.6rem', borderRadius: '4px', cursor: 'pointer', fontWeight: 700 }}>CONFIGURE</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '20px', borderRadius: '12px', background: '#0B1120', border: '1px solid #1E293B', color: '#fff', position: 'relative', overflow: 'hidden' }}>
|
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, var(--accent-cyan), transparent)' }}></div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Navigation size={20} color="var(--accent-cyan)" />
|
<Navigation size={22} color="var(--accent-cyan)" />
|
||||||
<span className="health-status-badge" style={{ background: 'rgba(59,130,246,0.15)', color: '#60A5FA' }}>
|
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-cyan)' }}>ACTIVE</span>
|
||||||
<span className="status-pulse" style={{ background: '#60A5FA', width: '6px', height: '6px' }}></span> ACTIVE
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1rem', fontWeight: 850, marginTop: '16px', letterSpacing: '-0.02em' }}>Network Hub</div>
|
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Network Hub</div>
|
||||||
<p style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '4px' }}>Multi-zone sync active.</p>
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>Multi-zone sync active.</p>
|
||||||
<div style={{ height: '1px', background: '#1E293B', margin: '16px 0' }}></div>
|
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||||
<button className="mono" style={{ width: '100%', padding: '10px', background: 'rgba(59,130,246,0.1)', border: '1px solid rgba(59,130,246,0.2)', color: '#60A5FA', fontSize: '0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 750, transition: 'all 0.2s' }}>NODES MAP</button>
|
<button className="mono" style={{ width: '100%', padding: '8px', background: 'rgba(59,130,246,0.1)', border: 'none', color: 'var(--accent-cyan)', fontSize: '0.6rem', borderRadius: '4px', cursor: 'pointer', fontWeight: 700 }}>NODES MAP</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Critical Task Cluster" className="premium-health-card" style={{ background: 'linear-gradient(to bottom, #fff, hsla(0, 80%, 98%, 0.5))', border: '1px solid rgba(239,68,68,0.2)' }}>
|
<Card title="Critical Task Cluster" style={{ background: 'rgba(255, 59, 59, 0.03)' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginTop: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Blood Link', status: 'Healthy', color: 'var(--accent-green)' },
|
{ label: 'Blood Link', status: 'Healthy', color: 'var(--accent-green)' },
|
||||||
{ label: 'Organ Registry', status: 'Healthy', color: 'var(--accent-green)' },
|
{ label: 'Organ Registry', status: 'Healthy', color: 'var(--accent-green)' },
|
||||||
{ label: 'Mortuary Sync', status: 'Delayed', color: 'var(--warning-amber)' },
|
{ label: 'Mortuary Sync', status: 'Delayed', color: 'var(--warning-amber)' },
|
||||||
{ label: 'Police V-Link', status: 'Healthy', color: 'var(--accent-green)' },
|
{ label: 'Police V-Link', status: 'Healthy', color: 'var(--accent-green)' },
|
||||||
].map((r, i) => (
|
].map((r, i) => (
|
||||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '12px', borderBottom: '1px dashed var(--card-border)' }}>
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '8px', borderBottom: '1px solid rgba(0,0,0,0.02)' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, color: 'var(--text-primary)' }}>{r.label}</div>
|
<div style={{ fontSize: '0.8rem', fontWeight: 700 }}>{r.label}</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: r.color, fontWeight: 700, marginTop: '2px' }}>{r.status}</div>
|
<div style={{ fontSize: '0.6rem', color: r.color }}>{r.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-pulse" style={{ background: r.color, width: '10px', height: '10px' }}></div>
|
<div className="status-pulse" style={{ background: r.color }}></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -761,8 +702,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
{/* SLA Ticker & Progress */}
|
{/* SLA Ticker & Progress */}
|
||||||
<div style={{ display: 'flex', gap: '24px', alignItems: 'stretch', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '24px', alignItems: 'stretch', flexWrap: 'wrap' }}>
|
||||||
<Card className="premium-health-card" style={{ flex: 1, padding: '24px' }}>
|
<Card style={{ flex: 1, padding: '16px 24px' }}>
|
||||||
<div style={{ display: 'grid', gap: '24px', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))' }}>
|
<div style={{ display: 'grid', gap: '16px', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Foundation', progress: 100 },
|
{ label: 'Foundation', progress: 100 },
|
||||||
{ label: 'MVP Core', progress: 100 },
|
{ label: 'MVP Core', progress: 100 },
|
||||||
@@ -771,11 +712,11 @@ export const Dashboard: React.FC = () => {
|
|||||||
{ label: 'Compliance', progress: 100 },
|
{ label: 'Compliance', progress: 100 },
|
||||||
].map((phase, i) => (
|
].map((phase, i) => (
|
||||||
<div key={phase.label} style={{ minWidth: 0 }}>
|
<div key={phase.label} style={{ minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.65rem', marginBottom: '6px' }}>
|
||||||
<span style={{ fontWeight: 750, color: 'var(--text-primary)' }}>{phase.label}</span>
|
<span style={{ fontWeight: 600 }}>{phase.label}</span>
|
||||||
<span className="mono" style={{ fontWeight: 850 }}>{phase.progress}%</span>
|
<span className="mono">{phase.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: '6px', background: 'rgba(0,0,0,0.05)', borderRadius: '3px', overflow: 'hidden' }}>
|
<div style={{ height: '4px', background: 'rgba(0,0,0,0.02)', borderRadius: '2px', overflow: 'hidden' }}>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: `${phase.progress}%` }}
|
animate={{ width: `${phase.progress}%` }}
|
||||||
@@ -783,7 +724,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
background: phase.progress === 100 ? 'var(--accent-green)' : 'var(--accent-cyan)',
|
background: phase.progress === 100 ? 'var(--accent-green)' : 'var(--accent-cyan)',
|
||||||
boxShadow: `0 0 10px ${phase.progress === 100 ? 'rgba(0,255,136,0.5)' : 'rgba(0,212,255,0.5)'}`
|
boxShadow: `0 0 10px ${phase.progress === 100 ? 'rgba(0,255,136,0.3)' : 'rgba(0,212,255,0.3)'}`
|
||||||
}}
|
}}
|
||||||
></motion.div>
|
></motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -792,35 +733,26 @@ export const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div style={{
|
<div className="glass" style={{
|
||||||
minWidth: '300px',
|
minWidth: '300px',
|
||||||
flex: '1 1 320px',
|
flex: '1 1 320px',
|
||||||
padding: '16px 24px',
|
padding: '16px',
|
||||||
background: '#0B1120',
|
border: '1px solid var(--card-border)',
|
||||||
border: '1px solid #1E293B',
|
|
||||||
borderRadius: '16px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '16px',
|
gap: '12px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap'
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.1)'
|
|
||||||
}}>
|
}}>
|
||||||
<div className="mono" style={{ fontSize: '0.75rem', fontWeight: 850, color: '#38BDF8', padding: '6px 12px', background: 'rgba(56, 189, 248, 0.15)', borderRadius: '6px', border: '1px solid rgba(56, 189, 248, 0.3)' }}>SLA TICKER</div>
|
<div className="mono" style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--accent-cyan)', padding: '4px 8px', background: 'rgba(59,130,246,0.1)', borderRadius: '4px' }}>SLA TICKER</div>
|
||||||
<div className="no-scrollbar" style={{ fontSize: '0.85rem', color: '#94A3B8', overflow: 'hidden', flex: 1, position: 'relative' }}>
|
<div className="no-scrollbar" style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', overflow: 'hidden', flex: 1, position: 'relative' }}>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ x: [500, -1000] }}
|
animate={{ x: [400, -800] }}
|
||||||
transition={{ duration: 30, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
||||||
style={{ display: 'inline-block', whiteSpace: 'nowrap', fontWeight: 750, letterSpacing: '0.02em' }}
|
style={{ display: 'inline-block', whiteSpace: 'nowrap', fontWeight: 700 }}
|
||||||
>
|
>
|
||||||
<span style={{ color: '#fff' }}>HIPAA COMPLIANT</span> <span style={{ color: '#34D399' }}>✅</span> |
|
HIPAA COMPLIANT ✅ | ABDM SYNCED ✅ | ISO 27001 AUDIT PASSED ✅ | PHI ENCRYPTED ✅ | DPDP ACT ALIGNED ✅ | END-TO-END TLS 1.3 ACTIVE ✅
|
||||||
<span style={{ color: '#fff' }}>ABDM SYNCED</span> <span style={{ color: '#34D399' }}>✅</span> |
|
|
||||||
<span style={{ color: '#fff' }}>ISO 27001 AUDIT PASSED</span> <span style={{ color: '#34D399' }}>✅</span> |
|
|
||||||
<span style={{ color: '#fff' }}>PHI ENCRYPTED</span> <span style={{ color: '#34D399' }}>✅</span> |
|
|
||||||
<span style={{ color: '#fff' }}>DPDP ACT ALIGNED</span> <span style={{ color: '#34D399' }}>✅</span> |
|
|
||||||
<span style={{ color: '#fff' }}>END-TO-END TLS 1.3 ACTIVE</span> <span style={{ color: '#34D399' }}>✅</span>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -314,8 +314,9 @@ const LiveLeafletMap: React.FC<{ vehicles: FleetVehicle[], selectedId: string |
|
|||||||
const LocationPickerMap: React.FC<{
|
const LocationPickerMap: React.FC<{
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
onLocationSelect: (lat: number, lng: number) => void
|
onLocationSelect: (lat: number, lng: number) => void;
|
||||||
}> = ({ lat, lng, onLocationSelect }) => {
|
notify?: (title: string, message: string, type?: 'success' | 'error' | 'info') => void;
|
||||||
|
}> = ({ lat, lng, onLocationSelect, notify }) => {
|
||||||
const [isMapReady, setIsMapReady] = useState(false);
|
const [isMapReady, setIsMapReady] = useState(false);
|
||||||
const mapRef = useRef<any>(null);
|
const mapRef = useRef<any>(null);
|
||||||
const markerRef = useRef<any>(null);
|
const markerRef = useRef<any>(null);
|
||||||
@@ -469,7 +470,11 @@ const LocationPickerMap: React.FC<{
|
|||||||
setIsLocating(false);
|
setIsLocating(false);
|
||||||
|
|
||||||
if (accuracy > 100) {
|
if (accuracy > 100) {
|
||||||
console.log('GPS Warning', 'Location accuracy is low. Please manually adjust the pin.', 'info');
|
if (notify) {
|
||||||
|
notify('GPS Warning', 'Location accuracy is low. Please manually adjust the pin.', 'info');
|
||||||
|
} else {
|
||||||
|
console.warn('GPS Warning: Location accuracy is low. Please manually adjust the pin.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -478,7 +483,11 @@ const LocationPickerMap: React.FC<{
|
|||||||
if (error.code === 1) msg = 'Location permission denied.';
|
if (error.code === 1) msg = 'Location permission denied.';
|
||||||
else if (error.code === 3) msg = 'Location request timed out.';
|
else if (error.code === 3) msg = 'Location request timed out.';
|
||||||
|
|
||||||
console.log('GPS Error', msg, 'error');
|
if (notify) {
|
||||||
|
notify('GPS Error', msg, 'error');
|
||||||
|
} else {
|
||||||
|
alert(msg);
|
||||||
|
}
|
||||||
setIsLocating(false);
|
setIsLocating(false);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1843,11 +1852,91 @@ const StationManifest: React.FC<{
|
|||||||
station: any;
|
station: any;
|
||||||
vehicles: any[];
|
vehicles: any[];
|
||||||
staff: any[];
|
staff: any[];
|
||||||
}> = ({ station, vehicles, staff }) => {
|
}> = ({ station }) => {
|
||||||
const stationVehicles = vehicles.filter(v => v.station_id === station.id || v.station_name === station.name);
|
const [stationVehicles, setStationVehicles] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// For demo/mock purposes, we'll assign some staff to the station
|
useEffect(() => {
|
||||||
const stationStaff = staff.slice(0, 3);
|
const fetchStationVehicles = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
if (!token) throw new Error('No authentication token found.');
|
||||||
|
|
||||||
|
console.log(`StationManifest: Fetching vehicles for station: ${station?.id}`);
|
||||||
|
const response = await fleetApi.getStationVehicles(token, station?.id);
|
||||||
|
|
||||||
|
let list = response.data?.data || (Array.isArray(response.data) ? response.data : []);
|
||||||
|
setStationVehicles(list);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch station vehicles:', err);
|
||||||
|
setError(err.message || 'Failed to fetch station vehicles');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (station?.id) {
|
||||||
|
fetchStationVehicles();
|
||||||
|
}
|
||||||
|
}, [station]);
|
||||||
|
|
||||||
|
// Extract unique active crew from fetched station vehicles
|
||||||
|
const stationStaff = useMemo(() => {
|
||||||
|
const crewMap = new Map<string, { id: string; name: string; type: string; phone?: string }>();
|
||||||
|
|
||||||
|
stationVehicles.forEach(v => {
|
||||||
|
// Check activeRoster
|
||||||
|
if (v.activeRoster) {
|
||||||
|
const roster = v.activeRoster;
|
||||||
|
if (roster.driver?.user) {
|
||||||
|
const d = roster.driver;
|
||||||
|
crewMap.set(d.id, {
|
||||||
|
id: d.id,
|
||||||
|
name: d.user.name || 'Unknown Pilot',
|
||||||
|
type: 'Pilot',
|
||||||
|
phone: d.user.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roster.staff?.user) {
|
||||||
|
const s = roster.staff;
|
||||||
|
crewMap.set(s.id, {
|
||||||
|
id: s.id,
|
||||||
|
name: s.user.name || 'Unknown EMT',
|
||||||
|
type: 'EMT',
|
||||||
|
phone: s.user.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check activeShift
|
||||||
|
if (v.activeShift) {
|
||||||
|
const shift = v.activeShift;
|
||||||
|
if (shift.driver?.user) {
|
||||||
|
const d = shift.driver;
|
||||||
|
crewMap.set(d.id, {
|
||||||
|
id: d.id,
|
||||||
|
name: d.user.name || 'Unknown Pilot',
|
||||||
|
type: 'Pilot',
|
||||||
|
phone: d.user.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (shift.staff?.user) {
|
||||||
|
const s = shift.staff;
|
||||||
|
crewMap.set(s.id, {
|
||||||
|
id: s.id,
|
||||||
|
name: s.user.name || 'Unknown EMT',
|
||||||
|
type: 'EMT',
|
||||||
|
phone: s.user.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(crewMap.values());
|
||||||
|
}, [stationVehicles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
@@ -1863,35 +1952,68 @@ const StationManifest: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
||||||
|
{/* Deployed Fleet */}
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '15px' }}>
|
<div className="glass" style={{ padding: '20px', borderRadius: '15px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||||
<Truck size={20} color="var(--accent-cyan)" />
|
<Truck size={20} color="var(--accent-cyan)" />
|
||||||
<h5 style={{ margin: 0, fontSize: '0.9rem', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Deployed Fleet ({stationVehicles.length})</h5>
|
<h5 style={{ margin: 0, fontSize: '0.9rem', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Deployed Fleet ({loading ? '...' : stationVehicles.length})</h5>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{stationVehicles.length === 0 ? (
|
{loading ? (
|
||||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', fontStyle: 'italic' }}>No vehicles currently docked.</div>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px', gap: '12px' }}>
|
||||||
|
<Loader2 className="spin-slow" color="var(--accent-cyan)" size={24} />
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>DECRYPTING STATION FLEET...</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--alert-red)', padding: '10px', textAlign: 'center' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : stationVehicles.length === 0 ? (
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', fontStyle: 'italic', textAlign: 'center', padding: '20px' }}>No vehicles currently docked.</div>
|
||||||
) : (
|
) : (
|
||||||
stationVehicles.map(v => (
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<div key={v.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: 'rgba(255,255,255,0.02)', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)' }}>
|
{stationVehicles.map(v => {
|
||||||
|
const driverName = v.activeRoster?.driver?.user?.name || v.activeShift?.driver?.user?.name;
|
||||||
|
const emtName = v.activeRoster?.staff?.user?.name || v.activeShift?.staff?.user?.name;
|
||||||
|
return (
|
||||||
|
<div key={v.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '12px', background: 'rgba(255,255,255,0.02)', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{v.registration_number}</div>
|
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{v.registration_number}</div>
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>{v.brand} {v.model}</div>
|
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>{v.brand} {v.model} • {v.vehicle_type}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="badge" style={{ fontSize: '0.6rem', background: v.status === 'AVAILABLE' ? 'rgba(0, 226, 114, 0.1)' : 'rgba(59, 130, 246, 0.1)', color: v.status === 'AVAILABLE' ? 'var(--accent-green)' : 'var(--accent-cyan)' }}>
|
<div className="badge" style={{ fontSize: '0.6rem', background: v.status === 'AVAILABLE' ? 'rgba(0, 226, 114, 0.1)' : 'rgba(255, 171, 0, 0.1)', color: v.status === 'AVAILABLE' ? 'var(--accent-green)' : 'var(--accent-amber)' }}>
|
||||||
{v.status}
|
{v.status}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
{(driverName || emtName) && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.04)', paddingTop: '6px', display: 'flex', flexWrap: 'wrap', gap: '8px', fontSize: '0.7rem' }}>
|
||||||
|
{driverName && <span style={{ color: 'var(--text-secondary)' }}>Pilot: <strong style={{ color: '#fff' }}>{driverName}</strong></span>}
|
||||||
|
{emtName && <span style={{ color: 'var(--text-secondary)' }}>EMT: <strong style={{ color: '#fff' }}>{emtName}</strong></span>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Active Crew */}
|
||||||
<div className="glass" style={{ padding: '20px', borderRadius: '15px' }}>
|
<div className="glass" style={{ padding: '20px', borderRadius: '15px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||||
<Users size={20} color="var(--accent-cyan)" />
|
<Users size={20} color="var(--accent-cyan)" />
|
||||||
<h5 style={{ margin: 0, fontSize: '0.9rem', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Active Crew ({stationStaff.length})</h5>
|
<h5 style={{ margin: 0, fontSize: '0.9rem', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Active Crew ({loading ? '...' : stationStaff.length})</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px', gap: '12px' }}>
|
||||||
|
<Loader2 className="spin-slow" color="var(--accent-cyan)" size={24} />
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>COMPILING CREW LIST...</div>
|
||||||
|
</div>
|
||||||
|
) : stationStaff.length === 0 ? (
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', fontStyle: 'italic', textAlign: 'center', padding: '20px' }}>No crew active at this station.</div>
|
||||||
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{stationStaff.map(s => (
|
{stationStaff.map(s => (
|
||||||
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '10px', background: 'rgba(255,255,255,0.02)', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)' }}>
|
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '10px', background: 'rgba(255,255,255,0.02)', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
@@ -1899,12 +2021,13 @@ const StationManifest: React.FC<{
|
|||||||
<User size={16} color="var(--accent-cyan)" />
|
<User size={16} color="var(--accent-cyan)" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{s.user?.name || s.name}</div>
|
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{s.name}</div>
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>{s.type} • On Call</div>
|
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>{s.type} • On Duty {s.phone ? `(${s.phone})` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1931,7 +2054,7 @@ const BrandRegistrationForm: React.FC<{ onSubmit: (data: any) => void; loading?:
|
|||||||
});
|
});
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2068,7 +2191,8 @@ const StationRegistrationForm: React.FC<{
|
|||||||
onSubmit: (data: any) => void;
|
onSubmit: (data: any) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
organisationId: string;
|
organisationId: string;
|
||||||
}> = ({ onSubmit, loading, organisationId }) => {
|
notify?: (title: string, message: string, type?: 'success' | 'error' | 'info') => void;
|
||||||
|
}> = ({ onSubmit, loading, organisationId, notify }) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
organisationId: organisationId,
|
organisationId: organisationId,
|
||||||
@@ -2079,7 +2203,7 @@ const StationRegistrationForm: React.FC<{
|
|||||||
phone: ''
|
phone: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2206,6 +2330,7 @@ const StationRegistrationForm: React.FC<{
|
|||||||
lat={parseFloat(formData.gps_lat) || 13.0827}
|
lat={parseFloat(formData.gps_lat) || 13.0827}
|
||||||
lng={parseFloat(formData.gps_lon) || 80.2707}
|
lng={parseFloat(formData.gps_lon) || 80.2707}
|
||||||
onLocationSelect={handleLocationSelect}
|
onLocationSelect={handleLocationSelect}
|
||||||
|
notify={notify}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2440,7 +2565,7 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
const roles = u.roles?.map((r: any) => String(r).trim().toUpperCase()) || [];
|
const roles = u.roles?.map((r: any) => String(r).trim().toUpperCase()) || [];
|
||||||
const isOp = roles.includes('FLEET_OPERATOR') ||
|
const isOp = roles.includes('FLEET_OPERATOR') ||
|
||||||
roles.includes('FLEET OPERATOR') ||
|
roles.includes('FLEET OPERATOR') ||
|
||||||
roles.some((r: string) => r.includes('FLEET') && r.includes('OPERATOR'));
|
roles.some(r => r.includes('FLEET') && r.includes('OPERATOR'));
|
||||||
|
|
||||||
if (!isOp) return false;
|
if (!isOp) return false;
|
||||||
|
|
||||||
@@ -2559,13 +2684,13 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Registration Success:', result);
|
console.log('Registration Success:', result);
|
||||||
console.log('Brand Registered', `Organisation "${data.metadata.organization.company_name}" has been onboarded.`, 'success');
|
notify('Brand Registered', `Organisation "${data.metadata.organization.company_name}" has been onboarded.`, 'success');
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Registration failed:', error);
|
console.error('Registration failed:', error);
|
||||||
const isExpired = error.message.includes('Token has expired');
|
const isExpired = error.message.includes('Token has expired');
|
||||||
console.log('Registration Failed', error.message, 'error');
|
notify('Registration Failed', error.message, 'error');
|
||||||
|
|
||||||
if (isExpired) {
|
if (isExpired) {
|
||||||
localStorage.removeItem('teleems_auth');
|
localStorage.removeItem('teleems_auth');
|
||||||
@@ -2596,13 +2721,13 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Dispatch: Station Created Successfully:', stationId);
|
console.log('Dispatch: Station Created Successfully:', stationId);
|
||||||
console.log('Station Created', `Station "${data.name}" is now active in the system.`, 'success');
|
notify('Station Created', `Station "${data.name}" is now active in the system.`, 'success');
|
||||||
|
|
||||||
setRefreshKey(prev => prev + 1); // Trigger re-fetch of fleet owners/stations
|
setRefreshKey(prev => prev + 1); // Trigger re-fetch of fleet owners/stations
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Dispatch: Station Creation Failed:', error);
|
console.error('Dispatch: Station Creation Failed:', error);
|
||||||
console.log('Station Error', error.message, 'error');
|
notify('Station Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2628,13 +2753,13 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
throw new Error(result.message || `Failed to ${data.id ? 'update' : 'create'} vehicle.`);
|
throw new Error(result.message || `Failed to ${data.id ? 'update' : 'create'} vehicle.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Vehicle Updated', `Vehicle "${data.registration_number}" telemetry is now synced.`, 'success');
|
notify('Vehicle Updated', `Vehicle "${data.registration_number}" telemetry is now synced.`, 'success');
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingVehicle(null);
|
setEditingVehicle(null);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Dispatch: Vehicle ${data.id ? 'Update' : 'Creation'} Failed:`, error);
|
console.error(`Dispatch: Vehicle ${data.id ? 'Update' : 'Creation'} Failed:`, error);
|
||||||
console.log('Vehicle Error', error.message, 'error');
|
notify('Vehicle Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2658,12 +2783,12 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Dispatch: Staff Registered Successfully');
|
console.log('Dispatch: Staff Registered Successfully');
|
||||||
console.log('Staff Registered', `Personnel file for "${data.name}" has been created.`, 'success');
|
notify('Staff Registered', `Personnel file for "${data.name}" has been created.`, 'success');
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Dispatch: Staff Registration Failed:', error);
|
console.error('Dispatch: Staff Registration Failed:', error);
|
||||||
console.log('Staff Error', error.message, 'error');
|
notify('Staff Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2682,11 +2807,11 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
throw new Error(result.message || 'Failed to create roster.');
|
throw new Error(result.message || 'Failed to create roster.');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Roster Created', 'Crew assignment has been finalized.', 'success');
|
notify('Roster Created', 'Crew assignment has been finalized.', 'success');
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Dispatch: Roster Creation Failed:', error);
|
console.error('Dispatch: Roster Creation Failed:', error);
|
||||||
console.log('Roster Error', error.message, 'error');
|
notify('Roster Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2705,12 +2830,12 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
throw new Error(result.message || 'Failed to start shift.');
|
throw new Error(result.message || 'Failed to start shift.');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Shift Started', 'Vehicle is now LIVE and tracking telemetry.', 'success');
|
notify('Shift Started', 'Vehicle is now LIVE and tracking telemetry.', 'success');
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Dispatch: Shift Start Failed:', error);
|
console.error('Dispatch: Shift Start Failed:', error);
|
||||||
console.log('Shift Error', error.message, 'error');
|
notify('Shift Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -3242,7 +3367,7 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{modalType === 'BRAND' && <BrandRegistrationForm onSubmit={handleBrandSubmit} loading={isSubmitting} />}
|
{modalType === 'BRAND' && <BrandRegistrationForm onSubmit={handleBrandSubmit} loading={isSubmitting} />}
|
||||||
{modalType === 'STATION' && <StationRegistrationForm organisationId={selectedOwnerId || ''} onSubmit={handleStationSubmit} loading={isSubmitting} />}
|
{modalType === 'STATION' && <StationRegistrationForm organisationId={selectedOwnerId || ''} onSubmit={handleStationSubmit} loading={isSubmitting} notify={notify} />}
|
||||||
{modalType === 'VEHICLE' && <VehicleRegistrationForm organisationId={selectedOwnerId || ''} onSubmit={handleVehicleSubmit} loading={isSubmitting} initialData={editingVehicle} />}
|
{modalType === 'VEHICLE' && <VehicleRegistrationForm organisationId={selectedOwnerId || ''} onSubmit={handleVehicleSubmit} loading={isSubmitting} initialData={editingVehicle} />}
|
||||||
{modalType === 'ROSTER' && <RosterForm vehicles={realVehicles} staff={realStaff} onSubmit={handleRosterSubmit} loading={isSubmitting} />}
|
{modalType === 'ROSTER' && <RosterForm vehicles={realVehicles} staff={realStaff} onSubmit={handleRosterSubmit} loading={isSubmitting} />}
|
||||||
{modalType === 'SHIFT' && <ShiftStartForm vehicles={realVehicles} staff={realStaff} onSubmit={handleShiftStart} loading={isSubmitting} />}
|
{modalType === 'SHIFT' && <ShiftStartForm vehicles={realVehicles} staff={realStaff} onSubmit={handleShiftStart} loading={isSubmitting} />}
|
||||||
|
|||||||
@@ -3,23 +3,20 @@ import { useNavigate, NavLink } from 'react-router-dom';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Truck,
|
Truck,
|
||||||
Zap,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Lock,
|
Lock,
|
||||||
User,
|
User,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Cpu,
|
Cpu,
|
||||||
Radio,
|
|
||||||
Activity,
|
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Crosshair,
|
MapPin,
|
||||||
Signal
|
Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import './Login.css'; // Reuse core login styles but we'll override some for the tactical look
|
import './Login.css';
|
||||||
|
|
||||||
export const FleetLogin = () => {
|
export const FleetLogin = () => {
|
||||||
const [username, setUsername] = useState('fleet_operator');
|
const [username, setUsername] = useState('fleet_operator');
|
||||||
@@ -28,7 +25,7 @@ export const FleetLogin = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showError, setShowError] = useState('');
|
const [showError, setShowError] = useState('');
|
||||||
const [mfaSessionToken, setMfaSessionToken] = useState('');
|
const [mfaSessionToken, setMfaSessionToken] = useState('');
|
||||||
const [tempUser, setTempUser] = useState<any>(null);
|
const [tempUser, setTempUser] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loginStep, setLoginStep] = useState<'login' | 'mfa'>('login');
|
const [loginStep, setLoginStep] = useState<'login' | 'mfa'>('login');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
@@ -39,43 +36,75 @@ export const FleetLogin = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setShowError('');
|
setShowError('');
|
||||||
|
|
||||||
// --- MOCK LOGIN FOR FLEET OPERATOR ---
|
// Clear any leftover mock tokens to ensure a real network call
|
||||||
if (username === 'fleet_operator' && password === 'Fleet@123') {
|
console.log('--- STARTING FLEET LOGIN ---');
|
||||||
setTimeout(() => {
|
console.log('Clearing local storage tokens...');
|
||||||
localStorage.setItem('teleems_auth', 'true');
|
localStorage.removeItem('teleems_token');
|
||||||
localStorage.setItem('teleems_token', 'mock-fleet-token-2026');
|
localStorage.removeItem('teleems_auth');
|
||||||
localStorage.setItem('teleems_user', JSON.stringify({
|
localStorage.removeItem('teleems_user');
|
||||||
id: 'fleet-op-001',
|
|
||||||
username: 'fleet_operator',
|
try {
|
||||||
roles: ['FLEET_OPERATOR'],
|
console.log('[FleetLogin] Logging in as:', username);
|
||||||
metadata: {
|
|
||||||
organization: { company_name: 'TeleEMS Fleet Services' }
|
// Clear old session first
|
||||||
}
|
localStorage.removeItem('teleems_token');
|
||||||
}));
|
localStorage.removeItem('teleems_user');
|
||||||
setIsLoading(false);
|
localStorage.removeItem('teleems_auth');
|
||||||
navigate('/fleet-operator');
|
|
||||||
}, 1000);
|
// Step 1: Login — use raw fetch, NOT apiClient (bypass mock/401 interceptors)
|
||||||
|
const loginRes = await fetch('https://teleems-api-gateway.onrender.com/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const loginJson = await loginRes.json();
|
||||||
|
console.log('[FleetLogin] Login response status:', loginRes.status, loginJson);
|
||||||
|
|
||||||
|
if (loginRes.status === 201 || loginRes.status === 200) {
|
||||||
|
if (loginJson.data?.mfa_required) {
|
||||||
|
setMfaSessionToken(loginJson.data.mfa_session_token || '');
|
||||||
|
setTempUser(loginJson.data.user || null);
|
||||||
|
setLoginStep('mfa');
|
||||||
|
} else {
|
||||||
|
const accessToken = loginJson.data?.access_token || '';
|
||||||
|
if (!accessToken) {
|
||||||
|
setShowError('Login failed: No access token received.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Store token immediately
|
||||||
const response = await authApi.login(username, password);
|
|
||||||
|
|
||||||
if (response.status === 201 || response.status === 200) {
|
|
||||||
if (response.data.mfa_required) {
|
|
||||||
setMfaSessionToken(response.data.mfa_session_token || '');
|
|
||||||
setTempUser(response.data.user || null);
|
|
||||||
setLoginStep('mfa');
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('teleems_auth', 'true');
|
localStorage.setItem('teleems_auth', 'true');
|
||||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
localStorage.setItem('teleems_token', accessToken);
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(response.data.user || {}));
|
console.log('[FleetLogin] Token stored. Fetching /auth/me...');
|
||||||
navigate('/fleet-operator');
|
|
||||||
|
// Step 2: Fetch real profile from /auth/me
|
||||||
|
try {
|
||||||
|
const meRes = await fetch('https://teleems-api-gateway.onrender.com/v1/auth/me', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const meJson = await meRes.json();
|
||||||
|
console.log('[FleetLogin] /auth/me status:', meRes.status, meJson);
|
||||||
|
|
||||||
|
const profile = meJson?.data || loginJson.data?.user || {};
|
||||||
|
const roles: string[] = Array.isArray(profile.roles) ? [...profile.roles] : ['Fleet Operator'];
|
||||||
|
localStorage.setItem('teleems_user', JSON.stringify({ ...profile, roles }));
|
||||||
|
} catch (meErr) {
|
||||||
|
console.warn('[FleetLogin] /auth/me failed, using login user data:', meErr);
|
||||||
|
const fallback = loginJson.data?.user || {};
|
||||||
|
localStorage.setItem('teleems_user', JSON.stringify({ ...fallback, roles: ['Fleet Operator'] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/fleet-operator?tab=organization');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setShowError(response.message || 'Access Denied: Invalid Credentials');
|
setShowError(loginJson?.message || 'Access Denied: Invalid Credentials');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
|
console.error('[FleetLogin] Error:', err);
|
||||||
setShowError('Tactical Network Unavailable: Check Connection');
|
setShowError('Tactical Network Unavailable: Check Connection');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -93,14 +122,17 @@ export const FleetLogin = () => {
|
|||||||
if (response.status === 201 || response.status === 200) {
|
if (response.status === 201 || response.status === 200) {
|
||||||
localStorage.setItem('teleems_auth', 'true');
|
localStorage.setItem('teleems_auth', 'true');
|
||||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
localStorage.setItem('teleems_token', response.data.access_token || '');
|
||||||
const userToStore = response.data.user || tempUser || {};
|
const baseUser: Record<string, unknown> = (response.data.user || tempUser || {}) as Record<string, unknown>;
|
||||||
userToStore.mfa_enabled = true;
|
const roles = Array.isArray(baseUser.roles) ? [...baseUser.roles] : [];
|
||||||
|
if (!roles.includes('FLEET_OPERATOR')) roles.push('FLEET_OPERATOR');
|
||||||
|
|
||||||
|
const userToStore = { ...baseUser, roles, mfa_enabled: true };
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
||||||
navigate('/fleet-operator');
|
navigate('/fleet-operator?tab=organization');
|
||||||
} else {
|
} else {
|
||||||
setShowError('Invalid Security Token');
|
setShowError('Invalid Security Token');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
setShowError('Token Verification Failed');
|
setShowError('Token Verification Failed');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -108,135 +140,238 @@ export const FleetLogin = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page fleet-login-theme" style={{ background: '#020617' }}>
|
<div className="fleet-login-container" style={{ display: 'flex', minHeight: '100vh', width: '100vw', backgroundColor: '#F8FAFC', fontFamily: "'Outfit', sans-serif", overflow: 'hidden' }}>
|
||||||
{/* Tactical Background Elements */}
|
|
||||||
<div className="login-grid-decor" style={{ opacity: 0.1, backgroundImage: 'linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
|
||||||
<div className="scanline" style={{ background: 'linear-gradient(to bottom, transparent 0%, rgba(59, 130, 246, 0.05) 50%, transparent 100%)' }} />
|
|
||||||
<div className="login-overlay" style={{ background: 'radial-gradient(circle at center, transparent 0%, rgba(2, 6, 23, 0.8) 100%)' }} />
|
|
||||||
|
|
||||||
{/* Decorative Radar/Circle */}
|
{/* LEFT SIDE - IMAGE & TACTICAL HUD */}
|
||||||
|
<div className="fleet-login-left" style={{
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '60px',
|
||||||
|
borderRight: '1px solid rgba(6, 182, 212, 0.15)'
|
||||||
|
}}>
|
||||||
|
{/* Background Image */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: 'url("https://images.unsplash.com/photo-1587582423116-ec07293f0395?q=80&w=2070&auto=format&fit=crop")',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: 'grayscale(20%) contrast(110%)'
|
||||||
|
}} />
|
||||||
|
{/* Gradients to blend image with the tactical theme */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'linear-gradient(to right, rgba(248, 250, 252, 0.95) 0%, rgba(248, 250, 252, 0.6) 40%, rgba(248, 250, 252, 0.95) 100%)'
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'radial-gradient(circle at 30% 50%, transparent 0%, rgba(248, 250, 252, 0.9) 100%)'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Decorative Grid & Radar (HUD elements) */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, opacity: 0.12, backgroundImage: 'linear-gradient(rgba(6, 182, 212, 0.2) 1px, transparent 1px), linear-gradient(90deg, rgba(6, 182, 212, 0.2) 1px, transparent 1px)', backgroundSize: '40px 40px', pointerEvents: 'none' }} />
|
||||||
|
|
||||||
|
{/* Content Top */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 10 }}>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: 360 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: '600px', height: '600px', border: '1px solid rgba(59, 130, 246, 0.05)', borderRadius: '50%', pointerEvents: 'none' }}
|
transition={{ duration: 0.8 }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '16px' }}
|
||||||
>
|
>
|
||||||
<div style={{ position: 'absolute', top: '0', left: '50%', width: '2px', height: '100%', background: 'linear-gradient(to bottom, rgba(59, 130, 246, 0.2), transparent)' }} />
|
<div style={{ padding: '12px', background: 'rgba(6, 182, 212, 0.08)', border: '1px solid #06b6d4', borderRadius: '12px', boxShadow: '0 4px 20px rgba(6, 182, 212, 0.15)' }}>
|
||||||
|
<Truck size={32} color="#06b6d4" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontSize: '28px', fontWeight: 900, color: '#0F172A', letterSpacing: '2px' }}>TELE_EMS</span>
|
||||||
|
<span style={{ fontSize: '12px', fontWeight: 700, color: '#06b6d4', letterSpacing: '4px' }}>FLEET_OPERATOR</span>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Middle/Bottom */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 10, maxWidth: '600px', marginBottom: '40px' }}>
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
|
style={{ fontSize: '56px', fontWeight: 900, lineHeight: 1.1, color: '#0F172A', marginBottom: '24px', letterSpacing: '-1px' }}
|
||||||
|
>
|
||||||
|
Command The Fleet.<br/>
|
||||||
|
<span style={{ color: '#06b6d4', textShadow: '0 0 30px rgba(6, 182, 212, 0.2)' }}>Save Lives Faster.</span>
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
|
style={{ fontSize: '18px', color: '#475569', lineHeight: 1.6, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
Access real-time telemetry, manage dispatch routes, and monitor critical resources from a single, secure tactical terminal.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.6 }}
|
||||||
|
style={{ display: 'flex', gap: '24px', marginTop: '40px' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#06b6d4', fontSize: '14px', fontWeight: 700 }}>
|
||||||
|
<Activity size={18} /> REAL-TIME SYNC
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#06b6d4', fontSize: '14px', fontWeight: 700 }}>
|
||||||
|
<MapPin size={18} /> GPS TRACKING
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#06b6d4', fontSize: '14px', fontWeight: 700 }}>
|
||||||
|
<ShieldCheck size={18} /> ENCRYPTED
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT SIDE - LOGIN FORM */}
|
||||||
|
<div className="fleet-login-right" style={{
|
||||||
|
width: '550px',
|
||||||
|
minWidth: '400px',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
boxShadow: '-10px 0 50px rgba(15, 23, 42, 0.04), -1px 0 0 rgba(15, 23, 42, 0.05)'
|
||||||
|
}}>
|
||||||
|
{/* Subtle background glow */}
|
||||||
|
<div style={{ position: 'absolute', top: '20%', right: '-10%', width: '300px', height: '300px', background: 'rgba(6, 182, 212, 0.03)', filter: 'blur(100px)', borderRadius: '50%', pointerEvents: 'none' }} />
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={loginStep}
|
key={loginStep}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, x: -20 }}
|
||||||
transition={{ duration: 0.5, ease: "circOut" }}
|
transition={{ duration: 0.5, ease: "circOut" }}
|
||||||
className="login-card glass"
|
style={{ width: '100%', maxWidth: '420px', display: 'flex', flexDirection: 'column', gap: '32px', zIndex: 10 }}
|
||||||
style={{
|
|
||||||
background: 'rgba(15, 23, 42, 0.8)',
|
|
||||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
|
||||||
boxShadow: '0 0 50px rgba(0, 0, 0, 0.5), inset 0 0 20px rgba(59, 130, 246, 0.1)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="login-header">
|
<div style={{ textAlign: 'center' }}>
|
||||||
<motion.div
|
<h2 style={{ fontSize: '28px', fontWeight: 800, color: '#0F172A', margin: '0 0 8px 0', letterSpacing: '1px' }}>
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
{loginStep === 'login' ? 'TERMINAL ACCESS' : 'MFA REQUIRED'}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
</h2>
|
||||||
className="login-logo"
|
<p style={{ color: '#06b6d4', fontSize: '13px', fontWeight: 600, letterSpacing: '2px', textTransform: 'uppercase', margin: 0, opacity: 0.9 }}>
|
||||||
style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)' }}
|
{loginStep === 'login' ? 'Sector: Dispatch • Active Node: CS-88' : 'Identity Verification'}
|
||||||
>
|
|
||||||
<Truck className="text-cyan-400" size={28} style={{ color: 'var(--accent-cyan)' }} />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<h1 className="login-title" style={{ letterSpacing: '0.1em', fontWeight: 900 }}>
|
|
||||||
{loginStep === 'login' ? 'FLEET TERMINAL' : 'SECURE TOKEN'}
|
|
||||||
</h1>
|
|
||||||
<p className="login-subtitle" style={{ color: 'var(--accent-cyan)', opacity: 0.8, fontSize: '0.7rem', fontWeight: 800, textTransform: 'uppercase' }}>
|
|
||||||
{loginStep === 'login' ? 'Sector: Dispatch • Active Node: CS-88' : 'Identity Verification Required'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loginStep === 'login' ? (
|
{loginStep === 'login' ? (
|
||||||
<form onSubmit={handleLogin} className="login-form">
|
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
<div className="input-group">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>Operator ID</label>
|
<label style={{ fontSize: '12px', fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', opacity: 0.9 }}>Operator ID</label>
|
||||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<User className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
<User size={18} color="#06b6d4" style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.9 }} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="login-input mono"
|
|
||||||
placeholder="ID_ENTRY"
|
placeholder="ID_ENTRY"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
style={{ color: '#fff' }}
|
|
||||||
required
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '16px 16px 16px 48px',
|
||||||
|
background: 'rgba(15, 23, 42, 0.03)', border: '1px solid rgba(15, 23, 42, 0.1)',
|
||||||
|
borderRadius: '12px', color: '#0F172A', fontSize: '15px', fontWeight: 500,
|
||||||
|
outline: 'none', transition: 'all 0.3s', fontFamily: "'Outfit', sans-serif"
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#06b6d4'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = 'rgba(15, 23, 42, 0.1)'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>Command Key</label>
|
<label style={{ fontSize: '12px', fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', opacity: 0.9 }}>Command Key</label>
|
||||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<Lock className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
<Lock size={18} color="#06b6d4" style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.9 }} />
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="login-input mono"
|
|
||||||
placeholder="KEY_REQUIRED"
|
placeholder="KEY_REQUIRED"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
style={{ color: '#fff' }}
|
|
||||||
required
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '16px 48px 16px 48px',
|
||||||
|
background: 'rgba(15, 23, 42, 0.03)', border: '1px solid rgba(15, 23, 42, 0.1)',
|
||||||
|
borderRadius: '12px', color: '#0F172A', fontSize: '15px', fontWeight: 500,
|
||||||
|
outline: 'none', transition: 'all 0.3s', fontFamily: "'Outfit', sans-serif", letterSpacing: showPassword ? 'normal' : '3px'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#06b6d4'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = 'rgba(15, 23, 42, 0.1)'}
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer', paddingRight: '12px' }}>
|
<button type="button" onClick={() => setShowPassword(!showPassword)} style={{ position: 'absolute', right: '16px', top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: 0 }}>
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="fleet-login-submit"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
className="login-button"
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--accent-cyan)',
|
marginTop: '12px', padding: '16px', background: '#06b6d4', color: '#FFFFFF',
|
||||||
color: '#000',
|
border: 'none', borderRadius: '12px', fontSize: '15px', fontWeight: 800, letterSpacing: '1px',
|
||||||
fontWeight: 900,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px',
|
||||||
boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)'
|
cursor: isLoading ? 'not-allowed' : 'pointer', transition: 'all 0.3s',
|
||||||
|
boxShadow: '0 8px 24px rgba(6, 182, 212, 0.25)'
|
||||||
}}
|
}}
|
||||||
|
onMouseOver={(e) => { if(!isLoading) { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 12px 32px rgba(6, 182, 212, 0.35)'; } }}
|
||||||
|
onMouseOut={(e) => { if(!isLoading) { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(6, 182, 212, 0.25)'; } }}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Cpu className="spin" size={20} />
|
<Cpu className="spin" size={20} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
INITIALIZE SESSION
|
Login
|
||||||
<ArrowRight size={20} />
|
<ArrowRight size={20} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleMfaVerify} className="login-form">
|
<form onSubmit={handleMfaVerify} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
<div className="input-group">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>TOTP Authorization</label>
|
<label style={{ fontSize: '12px', fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', opacity: 0.9 }}>TOTP Authorization</label>
|
||||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<KeyRound className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
<KeyRound size={18} color="#06b6d4" style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.9 }} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="login-input mono"
|
|
||||||
placeholder="000 000"
|
placeholder="000 000"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={mfaCode}
|
value={mfaCode}
|
||||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||||
style={{ color: '#fff' }}
|
|
||||||
required
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '16px 16px 16px 48px',
|
||||||
|
background: 'rgba(15, 23, 42, 0.03)', border: '1px solid rgba(15, 23, 42, 0.1)',
|
||||||
|
borderRadius: '12px', color: '#0F172A', fontSize: '20px', fontWeight: 600, letterSpacing: '4px', textAlign: 'center',
|
||||||
|
outline: 'none', transition: 'all 0.3s', fontFamily: "'Outfit', sans-serif"
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#06b6d4'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = 'rgba(15, 23, 42, 0.1)'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="login-button"
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{ background: 'var(--accent-cyan)', color: '#000', fontWeight: 900 }}
|
style={{
|
||||||
|
marginTop: '12px', padding: '16px', background: '#06b6d4', color: '#FFFFFF',
|
||||||
|
border: 'none', borderRadius: '12px', fontSize: '15px', fontWeight: 800, letterSpacing: '1px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer', transition: 'all 0.3s',
|
||||||
|
boxShadow: '0 8px 24px rgba(6, 182, 212, 0.25)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Cpu className="spin" size={20} />
|
<Cpu className="spin" size={20} />
|
||||||
@@ -252,45 +387,58 @@ export const FleetLogin = () => {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showError && (
|
{showError && (
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="security-badge" style={{ color: '#ef4444', border: '1px solid rgba(239, 68, 68, 0.2)', background: 'rgba(239, 68, 68, 0.05)' }}>
|
<motion.div
|
||||||
<ShieldAlert size={14} />
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '14px', background: 'rgba(239, 68, 68, 0.06)', border: '1px solid rgba(239, 68, 68, 0.2)', borderRadius: '10px', color: '#ef4444', fontSize: '13px', fontWeight: 600, marginTop: '8px' }}>
|
||||||
|
<ShieldAlert size={16} />
|
||||||
<span>{showError}</span>
|
<span>{showError}</span>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="security-badge" style={{ borderColor: 'rgba(59, 130, 246, 0.2)', background: 'rgba(59, 130, 246, 0.05)' }}>
|
<div style={{ marginTop: '20px', borderTop: '1px solid rgba(6, 182, 212, 0.1)', paddingTop: '24px', textAlign: 'center' }}>
|
||||||
<Signal size={14} color="var(--accent-cyan)" />
|
<NavLink to="/login" style={{ color: '#64748B', textDecoration: 'none', fontSize: '13px', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '8px', transition: 'color 0.2s' }} onMouseOver={(e) => e.currentTarget.style.color = '#06b6d4'} onMouseOut={(e) => e.currentTarget.style.color = '#64748B'}>
|
||||||
<span style={{ color: 'var(--accent-cyan)', fontWeight: 700 }}>SECURE UPLINK ESTABLISHED</span>
|
<ArrowRight size={14} style={{ transform: 'rotate(180deg)' }} /> RETURN TO STANDARD PORTAL
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="login-footer" style={{ marginTop: '24px', borderTop: '1px solid rgba(59, 130, 246, 0.1)', paddingTop: '16px', textAlign: 'center' }}>
|
|
||||||
<NavLink to="/login" style={{ color: 'var(--accent-cyan)', textDecoration: 'none', fontSize: '0.8rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', opacity: 0.7 }}>
|
|
||||||
<ArrowRight size={14} style={{ transform: 'rotate(180deg)' }} /> BACK TO STANDARD LOGIN
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Page-level status indicators */}
|
|
||||||
<div className="login-status-indicators" style={{ bottom: '40px', right: '40px' }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', background: 'rgba(15, 23, 42, 0.8)', padding: '8px 16px', borderRadius: '8px', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 800, color: 'var(--accent-cyan)' }}>COMMS_STRENGTH</span>
|
|
||||||
<div style={{ display: 'flex', gap: '2px' }}>
|
|
||||||
{[1, 2, 3, 4].map(i => <div key={i} style={{ width: '3px', height: i * 3, background: 'var(--accent-cyan)' }} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--accent-green)', fontSize: '0.7rem', fontWeight: 800 }}>
|
|
||||||
<Radio size={14} className="pulse" /> LIVE TELEMETRY SYNC
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-sys-log" style={{ bottom: '40px', left: '40px', opacity: 0.3 }}>
|
{/* Embedded Global Styles to avoid breaking any other page, since we are overriding locally */}
|
||||||
<p>TERMINAL_ID: DISPATCH-X7</p>
|
<style>{`
|
||||||
<p>PROTOCOL: CS-SECURE-v4</p>
|
@keyframes spin {
|
||||||
<p>ENCRYPTION: QUANTUM-SAFE</p>
|
0% { transform: rotate(0deg); }
|
||||||
</div>
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
input:-webkit-autofill:active{
|
||||||
|
-webkit-box-shadow: 0 0 0 30px #ffffff inset !important;
|
||||||
|
-webkit-text-fill-color: #0f172a !important;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.fleet-login-container {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
.fleet-login-left {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.fleet-login-right {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,339 +1,60 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Building2, Truck, Users, CalendarDays, ClipboardCheck,
|
||||||
Truck,
|
ShoppingCart, Map, MapPin, Navigation, Link2, Activity,
|
||||||
Zap,
|
Bell, Settings, Search, Database
|
||||||
ShieldCheck,
|
|
||||||
MapPin,
|
|
||||||
Clock,
|
|
||||||
Navigation,
|
|
||||||
AlertTriangle,
|
|
||||||
Fuel,
|
|
||||||
Gauge,
|
|
||||||
Thermometer,
|
|
||||||
Wind,
|
|
||||||
Bell,
|
|
||||||
Settings,
|
|
||||||
ChevronRight,
|
|
||||||
LayoutGrid,
|
|
||||||
Route as RouteIcon,
|
|
||||||
Users,
|
|
||||||
Search,
|
|
||||||
CheckCircle2,
|
|
||||||
ShoppingCart
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Card, StatCard } from '../components/Common';
|
|
||||||
import { fleetApi } from '../api/fleet';
|
|
||||||
import { incidentsApi } from '../api/incidents';
|
|
||||||
import type { Incident } from '../api/types';
|
|
||||||
import {
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
// --- NEW FLEET MODULES ---
|
import { FleetOrganization } from './fleet/FleetOrganization';
|
||||||
import { FleetAssets } from './fleet/FleetAssets';
|
import { FleetAssets } from './fleet/FleetAssets';
|
||||||
import { FleetPersonnel } from './fleet/FleetPersonnel';
|
import { FleetPersonnel } from './fleet/FleetPersonnel';
|
||||||
import { FleetInventory } from './fleet/FleetInventory';
|
|
||||||
import { FleetScheduling } from './fleet/FleetScheduling';
|
import { FleetScheduling } from './fleet/FleetScheduling';
|
||||||
|
import { FleetInventory } from './fleet/FleetInventory';
|
||||||
|
import { FleetWarehouseStock } from './fleet/FleetWarehouseStock';
|
||||||
|
import { FleetPendingRequests } from './fleet/FleetPendingRequests';
|
||||||
|
import { LiveDashboard } from './fleet/LiveDashboard';
|
||||||
|
import { FleetActiveShifts } from './fleet/FleetActiveShifts';
|
||||||
|
import { FleetTrips } from './fleet/FleetTrips';
|
||||||
|
import { FleetAnalytics } from './fleet/FleetAnalytics';
|
||||||
|
import './fleet/FleetOperator.css';
|
||||||
|
|
||||||
// --- MOCK DATA FOR THE ENHANCED FEEL ---
|
const SCOPE_MODULES = [
|
||||||
const MOCK_VEHICLES = [
|
{ id: 'overview', label: 'Live Dashboard', icon: Map, desc: 'Real-time fleet tracking & telemetry' },
|
||||||
{ id: 'V001', number: 'TN-01-AM-1024', status: 'EN_ROUTE', speed: 45, fuel: 82, lat: 13.0827, lng: 80.2707, type: 'ALS' },
|
{ id: 'organization', label: 'Stations', icon: Building2, desc: 'Manage stations and profiles' },
|
||||||
{ id: 'V002', number: 'TN-05-AM-5521', status: 'IDLE', speed: 0, fuel: 65, lat: 13.0067, lng: 80.2575, type: 'BLS' },
|
{ id: 'assets', label: 'Vehicle Management', icon: Truck, desc: 'Registration, Docs, Services' },
|
||||||
{ id: 'V003', number: 'TN-07-AM-1122', status: 'TRANSPORTING', speed: 52, fuel: 45, lat: 12.9667, lng: 80.2475, type: 'TRANSFER' },
|
{ id: 'personnel', label: 'Staff Management', icon: Users, desc: 'Pilots, EMTs, Doctors' },
|
||||||
{ id: 'V004', number: 'TN-09-AM-9988', status: 'AT_SCENE', speed: 0, fuel: 78, lat: 12.9941, lng: 80.1709, type: 'AIR' },
|
{ id: 'scheduling', label: 'Crew Scheduling', icon: CalendarDays, desc: 'Shift assignments & conflicts' },
|
||||||
|
{ id: 'attendance', label: 'Attendance & Duty', icon: ClipboardCheck, desc: 'Daily attendance & payroll' },
|
||||||
|
{ id: 'inventory', label: 'Inventory Management', icon: ShoppingCart, desc: 'Stock levels & consumption' },
|
||||||
|
{ id: 'trips', label: 'Trip Management', icon: MapPin, desc: 'Active, pending, and completed trips' },
|
||||||
|
{ id: 'telematics', label: 'GPS Telematics', icon: Navigation, desc: 'Geofencing, speed, SOS alerts' },
|
||||||
|
{ id: 'referrals', label: 'Referral Network', icon: Link2, desc: 'Hospitals & specialty routing' },
|
||||||
|
{ id: 'analytics', label: 'Fleet Analytics', icon: Activity, desc: 'KPIs, SLAs, and reports' },
|
||||||
|
{ id: 'warehouse', label: 'Warehouse Stock', icon: Database, desc: 'Central warehouse inventory overview' },
|
||||||
|
{ id: 'pending-requests', label: 'Stock Requests', icon: ShoppingCart, desc: 'View, approve, and complete supply restock requests' },
|
||||||
|
{ id: 'active-shifts', label: 'Active Shifts', icon: Activity, desc: 'Real-time tracking of active shifts' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const PERFORMANCE_DATA = [
|
const PlaceholderModule: React.FC<{ label: string; icon: React.ElementType }> = ({ label, icon: Icon }) => (
|
||||||
{ time: '08:00', trips: 12, response: 14 },
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', minHeight: '400px', color: '#94A3B8', gap: '16px' }}>
|
||||||
{ time: '10:00', trips: 18, response: 12 },
|
<Icon size={64} style={{ opacity: 0.15 }} />
|
||||||
{ time: '12:00', trips: 25, response: 18 },
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, color: '#fff', margin: 0 }}>{label}</h2>
|
||||||
{ time: '14:00', trips: 22, response: 15 },
|
<p style={{ margin: 0, opacity: 0.6 }}>This module is under active development.</p>
|
||||||
{ time: '16:00', trips: 30, response: 22 },
|
</div>
|
||||||
{ time: '18:00', trips: 28, response: 19 },
|
);
|
||||||
];
|
|
||||||
|
|
||||||
// --- LIVE MAP COMPONENT ---
|
|
||||||
const CommandMap: React.FC<{ vehicles: any[] }> = ({ vehicles }) => {
|
|
||||||
const [L, setL] = useState<any>(null);
|
|
||||||
const mapRef = React.useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const loadLeaflet = () => {
|
|
||||||
const leaflet = (window as any).L;
|
|
||||||
if (leaflet) {
|
|
||||||
setL(leaflet);
|
|
||||||
if (!mapRef.current) {
|
|
||||||
const m = leaflet.map('fleet-command-map', {
|
|
||||||
zoomControl: false,
|
|
||||||
attributionControl: false
|
|
||||||
}).setView([13.0827, 80.2707], 12);
|
|
||||||
|
|
||||||
leaflet.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
maxZoom: 20
|
|
||||||
}).addTo(m);
|
|
||||||
|
|
||||||
mapRef.current = m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!(window as any).L) {
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
||||||
document.head.appendChild(link);
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
|
||||||
script.async = true;
|
|
||||||
script.onload = loadLeaflet;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
} else {
|
|
||||||
loadLeaflet();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (mapRef.current) {
|
|
||||||
mapRef.current.remove();
|
|
||||||
mapRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!L || !mapRef.current) return;
|
|
||||||
const map = mapRef.current;
|
|
||||||
|
|
||||||
vehicles.forEach(v => {
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'IDLE': return '#94A3B8';
|
|
||||||
case 'EN_ROUTE': return '#3B82F6';
|
|
||||||
case 'AT_SCENE': return '#F59E0B';
|
|
||||||
case 'TRANSPORTING': return '#EF4444';
|
|
||||||
case 'AT_HOSPITAL': return '#A855F7';
|
|
||||||
case 'BREAKDOWN': return '#000000';
|
|
||||||
case 'OFF_DUTY': return '#FFFFFF';
|
|
||||||
default: return '#3B82F6';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const color = getStatusColor(v.status);
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: 'custom-marker',
|
|
||||||
html: `<div style="background: ${color}; width: 24px; height: 24px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; box-shadow: 0 0 15px ${color}; border: 2px solid #fff;">
|
|
||||||
<div style="transform: rotate(45deg); color: #fff;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-1.1 0-2 .9-2 2v9c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg></div>
|
|
||||||
</div>`,
|
|
||||||
iconSize: [24, 24],
|
|
||||||
iconAnchor: [12, 24]
|
|
||||||
});
|
|
||||||
L.marker([v.lat, v.lng], { icon }).addTo(map).bindPopup(`<b>${v.number}</b><br/>Status: ${v.status}`);
|
|
||||||
});
|
|
||||||
}, [L, vehicles]);
|
|
||||||
|
|
||||||
return <div id="fleet-command-map" style={{ height: '100%', minHeight: '400px', borderRadius: '16px' }} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FleetOperatorDashboard: React.FC = () => {
|
export const FleetOperatorDashboard: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const activeTab = searchParams.get('tab') || 'overview';
|
const activeModule = searchParams.get('tab') || 'overview';
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const activeModuleInfo = SCOPE_MODULES.find(m => m.id === activeModule);
|
||||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
|
||||||
const [vehicles, setVehicles] = useState(MOCK_VEHICLES);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const renderModuleContent = () => {
|
||||||
const fetchData = async () => {
|
switch (activeModule) {
|
||||||
try {
|
case 'organization':
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
return <FleetOrganization />;
|
||||||
const res = await incidentsApi.getIncidents({}, token);
|
|
||||||
if (res && res.data) setIncidents(res.data.slice(0, 5));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ id: 'overview', label: 'Command Center', icon: LayoutGrid },
|
|
||||||
{ id: 'assets', label: 'Fleet Assets', icon: Truck },
|
|
||||||
{ id: 'personnel', label: 'Personnel Hub', icon: Users },
|
|
||||||
{ id: 'scheduling', label: 'Mission Control', icon: Navigation },
|
|
||||||
{ id: 'inventory', label: 'Supply Chain', icon: ShoppingCart },
|
|
||||||
{ id: 'analytics', label: 'Fleet Intel', icon: Activity },
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'overview':
|
|
||||||
return (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '24px', marginBottom: '32px' }}>
|
|
||||||
<StatCard label="Live Fleet" value="48" icon={Truck} glowColor="cyan" trend={{ value: '12%', isUp: true }} />
|
|
||||||
<StatCard label="Active Trips" value="12" icon={Activity} glowColor="green" pulse />
|
|
||||||
<StatCard label="Avg Response" value="8.4m" icon={Zap} glowColor="amber" trend={{ value: '0.5m', isUp: false }} />
|
|
||||||
<StatCard label="Compliance" value="99.2%" icon={ShieldCheck} glowColor="cyan" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px' }}>
|
|
||||||
{/* Left Column: Map and Fleet List */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
|
||||||
<Card style={{ height: '500px', padding: 0, overflow: 'hidden', border: '1px solid rgba(59, 130, 246, 0.3)', position: 'relative' }}>
|
|
||||||
<CommandMap vehicles={vehicles} />
|
|
||||||
<div style={{ position: 'absolute', top: '20px', left: '20px', zIndex: 1000 }}>
|
|
||||||
<div className="glass" style={{ padding: '12px', borderRadius: '12px', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#3B82F6' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>EN ROUTE</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#94A3B8' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>IDLE</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#F59E0B' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>AT SCENE</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#EF4444' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>TRANSPORTING</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#A855F7' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>HOSPITAL</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#000000' }}></div>
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 600 }}>BREAKDOWN</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Live Fleet Telemetry">
|
|
||||||
<div className="table-container no-scrollbar" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.1)', textAlign: 'left' }}>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Vehicle</th>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Status</th>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Speed</th>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Fuel</th>
|
|
||||||
<th style={{ padding: '12px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{vehicles.map(v => (
|
|
||||||
<tr key={v.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ fontWeight: 700 }}>{v.number}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.5 }}>{v.type} UNIT</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: v.status === 'IDLE' ? 'rgba(148, 163, 184, 0.1)' : 'rgba(59, 130, 246, 0.1)',
|
|
||||||
color: v.status === 'IDLE' ? '#94A3B8' : 'var(--accent-cyan)',
|
|
||||||
border: `1px solid ${v.status === 'IDLE' ? 'rgba(148, 163, 184, 0.2)' : 'rgba(59, 130, 246, 0.2)'}`
|
|
||||||
}}>{v.status}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }} className="mono">{v.speed} km/h</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ width: '100px', height: '6px', background: 'rgba(255,255,255,0.1)', borderRadius: '3px', overflow: 'hidden' }}>
|
|
||||||
<div style={{ width: `${v.fuel}%`, height: '100%', background: v.fuel < 30 ? 'var(--alert-red)' : 'var(--accent-green)' }}></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<button className="btn-ghost-sm"><Navigation size={14} /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Performance and Incidents */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
|
||||||
<Card title="Operational Load">
|
|
||||||
<div style={{ height: '200px', width: '100%', position: 'relative' }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
|
||||||
<AreaChart data={PERFORMANCE_DATA}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorTrips" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="var(--accent-cyan)" stopOpacity={0.3}/>
|
|
||||||
<stop offset="95%" stopColor="var(--accent-cyan)" stopOpacity={0}/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ background: '#1E293B', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px' }}
|
|
||||||
itemStyle={{ color: 'var(--accent-cyan)' }}
|
|
||||||
/>
|
|
||||||
<Area type="monotone" dataKey="trips" stroke="var(--accent-cyan)" fillOpacity={1} fill="url(#colorTrips)" />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Active Incident Feed">
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{incidents.length > 0 ? incidents.map(inc => (
|
|
||||||
<div key={inc.id} className="glass hover-glow" style={{ padding: '16px', borderRadius: '12px', borderLeft: `4px solid ${inc.severity === 'CRITICAL' ? 'var(--alert-red)' : 'var(--accent-cyan)'}` }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
|
||||||
<span className="mono" style={{ fontSize: '0.8rem', fontWeight: 800 }}>#{inc.id.split('-').pop()?.toUpperCase()}</span>
|
|
||||||
<span style={{ fontSize: '0.6rem', color: 'var(--alert-red)', fontWeight: 900 }}>{inc.severity}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '4px' }}>{inc.category}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', opacity: 0.6, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<MapPin size={12} /> {inc.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<div style={{ textAlign: 'center', padding: '20px', opacity: 0.5 }}>No active incidents</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button style={{ width: '100%', marginTop: '16px', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: 'var(--accent-cyan)', fontWeight: 700, cursor: 'pointer' }}>
|
|
||||||
VIEW ALL INCIDENTS
|
|
||||||
</button>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Fleet Health Indicators">
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
||||||
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderRadius: '12px', textAlign: 'center' }}>
|
|
||||||
<Gauge size={24} color="var(--accent-cyan)" style={{ marginBottom: '8px' }} />
|
|
||||||
<div style={{ fontSize: '1.25rem', fontWeight: 800 }}>82%</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>AVAILABILITY</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderRadius: '12px', textAlign: 'center' }}>
|
|
||||||
<Fuel size={24} color="var(--accent-green)" style={{ marginBottom: '8px' }} />
|
|
||||||
<div style={{ fontSize: '1.25rem', fontWeight: 800 }}>94%</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>FUEL READINESS</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'assets':
|
case 'assets':
|
||||||
return <FleetAssets />;
|
return <FleetAssets />;
|
||||||
case 'personnel':
|
case 'personnel':
|
||||||
@@ -342,53 +63,101 @@ export const FleetOperatorDashboard: React.FC = () => {
|
|||||||
return <FleetScheduling />;
|
return <FleetScheduling />;
|
||||||
case 'inventory':
|
case 'inventory':
|
||||||
return <FleetInventory />;
|
return <FleetInventory />;
|
||||||
|
case 'warehouse':
|
||||||
|
return <FleetWarehouseStock />;
|
||||||
|
case 'pending-requests':
|
||||||
|
return <FleetPendingRequests />;
|
||||||
|
case 'active-shifts':
|
||||||
|
return <FleetActiveShifts />;
|
||||||
|
case 'overview':
|
||||||
|
return <LiveDashboard />;
|
||||||
|
case 'attendance':
|
||||||
|
return <PlaceholderModule label="Attendance & Duty" icon={ClipboardCheck} />;
|
||||||
|
case 'trips':
|
||||||
|
return <FleetTrips />;
|
||||||
|
case 'telematics':
|
||||||
|
return <PlaceholderModule label="GPS Telematics" icon={Navigation} />;
|
||||||
|
case 'referrals':
|
||||||
|
return <PlaceholderModule label="Referral Network" icon={Link2} />;
|
||||||
case 'analytics':
|
case 'analytics':
|
||||||
return <div className="glass" style={{ padding: '40px', textAlign: 'center', color: 'var(--text-secondary)' }}>Fleet Intelligence Reports Loading...</div>;
|
return <FleetAnalytics />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return <PlaceholderModule label="Module Loading" icon={Activity} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fleet-operator-dashboard" style={{
|
<div className="fleet-dashboard-container" style={{
|
||||||
background: '#020617',
|
|
||||||
color: '#F8FAFC',
|
|
||||||
minHeight: '100vh',
|
|
||||||
fontFamily: "'Inter', sans-serif",
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
background: '#040B16',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
fontFamily: "'Inter', sans-serif"
|
||||||
}}>
|
}}>
|
||||||
{/* Main Content Area */}
|
{/* Top Header */}
|
||||||
<div style={{
|
<header className="fleet-dashboard-header" style={{
|
||||||
flex: 1,
|
height: '72px',
|
||||||
padding: '32px',
|
padding: '0 32px',
|
||||||
maxWidth: '100%'
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
background: 'rgba(15, 23, 42, 0.5)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
zIndex: 40,
|
||||||
|
flexShrink: 0
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '32px' }}>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: '2.5rem', fontWeight: 900, letterSpacing: '-0.05em', color: 'var(--accent-cyan)', textTransform: 'uppercase' }}>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 900, color: '#fff', margin: 0, letterSpacing: '-0.5px' }}>
|
||||||
{menuItems.find(m => m.id === activeTab)?.label || 'Fleet Command'}
|
{activeModuleInfo?.label || 'Fleet Command'}
|
||||||
</h1>
|
</h1>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', opacity: 0.7 }}>
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', display: 'flex', alignItems: 'center', gap: '8px', marginTop: '3px' }}>
|
||||||
<div className="status-pulse" style={{ background: 'var(--accent-green)' }}></div>
|
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#10B981', boxShadow: '0 0 8px #10B981' }} />
|
||||||
<span style={{ fontSize: '0.875rem' }}>Strategic Operations • Live Platform Telemetry</span>
|
{activeModuleInfo?.desc || 'Secure Connection • TeleEMS Fleet'}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<div style={{ background: 'rgba(255,255,255,0.05)', padding: '8px 16px', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', textTransform: 'uppercase', opacity: 0.5 }}>Tactical Time</div>
|
|
||||||
<div style={{ fontWeight: 700 }}>{new Date().toLocaleTimeString()}</div>
|
|
||||||
</div>
|
|
||||||
<Clock size={20} color="var(--accent-cyan)" />
|
|
||||||
</div>
|
|
||||||
<button className="btn-icon glass"><Bell size={20} /></button>
|
|
||||||
<button className="btn-icon glass"><Settings size={20} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderContent()}
|
<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' }} />
|
||||||
|
</button>
|
||||||
|
<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', display: 'flex' }}>
|
||||||
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="fleet-header-divider" style={{ width: '1px', height: '24px', background: 'rgba(255,255,255,0.07)' }} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<div className="fleet-profile-name" style={{ textAlign: 'right' }}>
|
||||||
|
<div className="fleet-profile-title" style={{ fontSize: '0.8rem', fontWeight: 700, color: '#fff' }}>Station Incharge</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#06B6D4' }}>Fleet Operator</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '34px', height: '34px', borderRadius: '10px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Users size={16} color="#fff" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="fleet-dashboard-content" style={{ flex: 1, overflowY: 'auto', padding: '32px' }}>
|
||||||
|
<motion.div
|
||||||
|
key={activeModule}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{renderModuleContent()}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,634 +0,0 @@
|
|||||||
/* ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
HOSPITAL LOGIN — Premium Medical Theme
|
|
||||||
═══════════════════════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
/* ─── PAGE SHELL ─────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
width: 100vw;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(145deg, #F0FDFA 0%, #F8FAFC 40%, #ECFDF5 100%);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── BACKGROUND EFFECTS ─────────────────────────────────────────────────────── */
|
|
||||||
.hosp-bg-grid {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(ellipse at 30% 20%, rgba(16, 185, 129, 0.06) 0%, transparent 55%),
|
|
||||||
radial-gradient(ellipse at 70% 80%, rgba(13, 148, 136, 0.04) 0%, transparent 55%),
|
|
||||||
linear-gradient(rgba(16, 185, 129, 0.02) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(16, 185, 129, 0.02) 1px, transparent 1px);
|
|
||||||
background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-bg-orb {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(100px);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orb-1 {
|
|
||||||
width: 600px;
|
|
||||||
height: 600px;
|
|
||||||
top: -200px;
|
|
||||||
left: -100px;
|
|
||||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.12) 0%, transparent 70%);
|
|
||||||
animation: orb-float 18s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orb-2 {
|
|
||||||
width: 500px;
|
|
||||||
height: 500px;
|
|
||||||
bottom: -150px;
|
|
||||||
right: -100px;
|
|
||||||
background: radial-gradient(circle, rgba(13, 148, 136, 0.1) 0%, transparent 70%);
|
|
||||||
animation: orb-float 22s ease-in-out infinite alternate-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orb-3 {
|
|
||||||
width: 350px;
|
|
||||||
height: 350px;
|
|
||||||
top: 50%;
|
|
||||||
left: 60%;
|
|
||||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.06) 0%, transparent 70%);
|
|
||||||
animation: orb-float 15s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes orb-float {
|
|
||||||
0% { transform: translate(0, 0) scale(1); }
|
|
||||||
50% { transform: translate(60px, -40px) scale(1.1); }
|
|
||||||
100% { transform: translate(-30px, 30px) scale(0.95); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-scan-line {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(16, 185, 129, 0.4), transparent);
|
|
||||||
animation: hosp-scan 4s linear infinite;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hosp-scan {
|
|
||||||
0% { top: -2px; opacity: 0; }
|
|
||||||
10% { opacity: 1; }
|
|
||||||
90% { opacity: 1; }
|
|
||||||
100% { top: 100vh; opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── FLOATING MEDICAL ICONS ─────────────────────────────────────────────────── */
|
|
||||||
.hosp-floating-icons {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-icon {
|
|
||||||
position: absolute;
|
|
||||||
color: rgba(16, 185, 129, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fi-1 { top: 15%; left: 12%; }
|
|
||||||
.fi-2 { top: 60%; right: 15%; }
|
|
||||||
.fi-3 { bottom: 20%; left: 20%; }
|
|
||||||
|
|
||||||
/* ─── LOGIN CARD ─────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-login-card {
|
|
||||||
width: 460px;
|
|
||||||
max-width: calc(100vw - 32px);
|
|
||||||
padding: 48px 44px 40px;
|
|
||||||
z-index: 2;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
backdrop-filter: blur(40px) saturate(1.6);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
|
||||||
border-radius: 28px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(16, 185, 129, 0.04),
|
|
||||||
0 30px 80px rgba(0, 0, 0, 0.07),
|
|
||||||
0 0 60px rgba(16, 185, 129, 0.04),
|
|
||||||
0 2px 6px rgba(0, 0, 0, 0.03);
|
|
||||||
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-login-card.success-glow {
|
|
||||||
border-color: rgba(16, 185, 129, 0.4);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 2px rgba(16, 185, 129, 0.15),
|
|
||||||
0 30px 80px rgba(16, 185, 129, 0.1),
|
|
||||||
0 0 100px rgba(16, 185, 129, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-card-accent {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 15%;
|
|
||||||
right: 15%;
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(90deg, transparent, #10b981, #0d9488, transparent);
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-card-accent::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 60%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.8), transparent);
|
|
||||||
animation: accent-shimmer 3s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes accent-shimmer {
|
|
||||||
0% { left: -60%; }
|
|
||||||
100% { left: 160%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── HEADER ─────────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-login-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-logo-container {
|
|
||||||
position: relative;
|
|
||||||
width: 68px;
|
|
||||||
height: 68px;
|
|
||||||
margin: 0 auto 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-logo-inner {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(13, 148, 136, 0.08));
|
|
||||||
border: 1.5px solid rgba(16, 185, 129, 0.35);
|
|
||||||
border-radius: 18px;
|
|
||||||
color: #0d9488;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
box-shadow: 0 0 30px rgba(16, 185, 129, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-logo-ring {
|
|
||||||
position: absolute;
|
|
||||||
inset: -4px;
|
|
||||||
border: 1.5px solid rgba(16, 185, 129, 0.15);
|
|
||||||
border-radius: 22px;
|
|
||||||
animation: ring-pulse 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ring-pulse {
|
|
||||||
0%, 100% { opacity: 0.5; transform: scale(1); }
|
|
||||||
50% { opacity: 0.15; transform: scale(1.1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-logo-pulse {
|
|
||||||
position: absolute;
|
|
||||||
inset: -8px;
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.08);
|
|
||||||
border-radius: 26px;
|
|
||||||
animation: ring-pulse 3s ease-in-out infinite 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-title {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f172a;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
letter-spacing: -0.7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-subtitle {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── FORM ───────────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #64748b;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input-icon {
|
|
||||||
position: absolute;
|
|
||||||
left: 16px;
|
|
||||||
color: #94a3b8;
|
|
||||||
transition: color 0.3s;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
border: 1.5px solid rgba(0, 0, 0, 0.06);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 14px 16px 14px 48px;
|
|
||||||
color: #0f172a;
|
|
||||||
font-family: 'Outfit', 'Inter', sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
outline: none;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input.mono {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
letter-spacing: 4px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 20px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input::placeholder {
|
|
||||||
color: rgba(0, 0, 0, 0.25);
|
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input:focus {
|
|
||||||
background: rgba(16, 185, 129, 0.03);
|
|
||||||
border-color: rgba(16, 185, 129, 0.45);
|
|
||||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08), 0 0 20px rgba(16, 185, 129, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input:focus ~ .hosp-input-icon,
|
|
||||||
.hosp-input-wrap:focus-within .hosp-input-icon {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input-focus-line {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
right: 50%;
|
|
||||||
height: 2px;
|
|
||||||
background: linear-gradient(90deg, #10b981, #0d9488);
|
|
||||||
border-radius: 0 0 14px 14px;
|
|
||||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-input:focus ~ .hosp-input-focus-line {
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-eye-btn {
|
|
||||||
position: absolute;
|
|
||||||
right: 14px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #94a3b8;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
display: flex;
|
|
||||||
z-index: 2;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-eye-btn:hover { color: #10b981; }
|
|
||||||
|
|
||||||
/* ─── EXTRAS ─────────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-extras {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-remember {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
color: #64748b;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
font-weight: 500;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-remember input[type="checkbox"] {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 1.5px solid rgba(0, 0, 0, 0.12);
|
|
||||||
border-radius: 5px;
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.25s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-remember input[type="checkbox"]:checked {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
border-color: rgba(16, 185, 129, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-remember input[type="checkbox"]:checked::after {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: #10b981;
|
|
||||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-forgot {
|
|
||||||
color: #0d9488;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 12.5px;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-forgot:hover { color: #10b981; }
|
|
||||||
|
|
||||||
/* ─── SUBMIT BUTTON ──────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-submit-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 54px;
|
|
||||||
background: linear-gradient(135deg, #10b981, #0d9488);
|
|
||||||
border: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 14px;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
box-shadow:
|
|
||||||
0 8px 30px rgba(16, 185, 129, 0.25),
|
|
||||||
0 0 0 1px rgba(16, 185, 129, 0.2);
|
|
||||||
font-family: 'Outfit', sans-serif;
|
|
||||||
text-transform: uppercase;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-submit-btn::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(135deg, transparent 40%, rgba(255,255,255,0.15) 50%, transparent 60%);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.6s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-submit-btn:hover:not(:disabled)::before {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-submit-btn:hover:not(:disabled) {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow:
|
|
||||||
0 14px 40px rgba(16, 185, 129, 0.35),
|
|
||||||
0 0 0 1px rgba(16, 185, 129, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-submit-btn:active:not(:disabled) {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-submit-btn:disabled {
|
|
||||||
opacity: 0.8;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-loader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-success-check {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── MFA ────────────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-mfa-hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
margin: 0 0 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-back-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 12px;
|
|
||||||
transition: color 0.2s;
|
|
||||||
font-family: 'Outfit', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-back-btn:hover { color: #0d9488; }
|
|
||||||
|
|
||||||
/* ─── ERROR BADGE ────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-error-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
color: #EF4444;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
background: rgba(239, 68, 68, 0.06);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── SECURITY BADGE ─────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-security-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
color: #10b981;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: rgba(16, 185, 129, 0.06);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.12);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── FOOTER ─────────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-footer {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-portal-link {
|
|
||||||
color: #64748b;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-portal-link:hover {
|
|
||||||
color: #0d9488;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── STATUS BAR ─────────────────────────────────────────────────────────────── */
|
|
||||||
.hosp-status-bar {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 28px;
|
|
||||||
right: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-status-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: #10b981;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-status-dot {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
background: #10b981;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: dot-blink 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dot-blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.hosp-login-card {
|
|
||||||
padding: 40px 28px 32px;
|
|
||||||
border-radius: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-title { font-size: 24px; }
|
|
||||||
.hosp-subtitle { font-size: 11px; letter-spacing: 1.5px; }
|
|
||||||
.float-icon { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.hosp-login-card {
|
|
||||||
padding: 32px 20px 28px;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-title { font-size: 22px; }
|
|
||||||
.hosp-subtitle { font-size: 10px; }
|
|
||||||
.hosp-status-bar { display: none; }
|
|
||||||
|
|
||||||
.hosp-submit-btn {
|
|
||||||
height: 50px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hosp-extras {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 360px) {
|
|
||||||
.hosp-login-card {
|
|
||||||
padding: 28px 16px 24px;
|
|
||||||
}
|
|
||||||
.hosp-logo-container { width: 56px; height: 56px; }
|
|
||||||
.hosp-logo-inner { width: 46px; height: 46px; border-radius: 14px; }
|
|
||||||
}
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useNavigate, NavLink } from 'react-router-dom';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
ShieldCheck,
|
|
||||||
Lock,
|
|
||||||
User,
|
|
||||||
ArrowRight,
|
|
||||||
Cpu,
|
|
||||||
Activity,
|
|
||||||
KeyRound,
|
|
||||||
ShieldAlert,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Building,
|
|
||||||
HeartPulse,
|
|
||||||
Stethoscope,
|
|
||||||
Monitor,
|
|
||||||
Wifi,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { hospitalApi } from '../api/hospital';
|
|
||||||
import './HospitalLogin.css';
|
|
||||||
|
|
||||||
export const HospitalLogin: React.FC = () => {
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [mfaCode, setMfaCode] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showError, setShowError] = useState('');
|
|
||||||
const [mfaSessionToken, setMfaSessionToken] = useState('');
|
|
||||||
const [tempUser, setTempUser] = useState<any>(null);
|
|
||||||
const [loginStep, setLoginStep] = useState<'login' | 'mfa'>('login');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
setShowError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await hospitalApi.login(username, password);
|
|
||||||
|
|
||||||
if (response.status === 201 || response.status === 200) {
|
|
||||||
if (response.data?.mfa_required) {
|
|
||||||
setMfaSessionToken(response.data.mfa_session_token || '');
|
|
||||||
setTempUser(response.data.user || null);
|
|
||||||
setLoginStep('mfa');
|
|
||||||
} else {
|
|
||||||
// Verify hospital role
|
|
||||||
const user = response.data?.user;
|
|
||||||
const roles = Array.isArray(user?.roles)
|
|
||||||
? user.roles.map((r: string) => r.toUpperCase())
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const hasHospitalAccess = roles.some((r: string) =>
|
|
||||||
['HOSPITAL_ADMIN', 'HOSPITAL ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT', 'CURESELECT_ADMIN', 'ADMIN'].includes(r)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasHospitalAccess) {
|
|
||||||
setShowError('This account does not have hospital access privileges');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success animation then redirect
|
|
||||||
setLoginSuccess(true);
|
|
||||||
localStorage.setItem('teleems_auth', 'true');
|
|
||||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(user || {}));
|
|
||||||
|
|
||||||
setTimeout(() => navigate('/'), 800);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let apiError = 'Invalid credentials. Please try again.';
|
|
||||||
const resAny = response as any;
|
|
||||||
if (resAny.error && typeof resAny.error === 'object') {
|
|
||||||
if (Array.isArray(resAny.error.details) && resAny.error.details.length > 0) {
|
|
||||||
apiError = resAny.error.details.join('; ');
|
|
||||||
} else {
|
|
||||||
apiError = resAny.error.message || apiError;
|
|
||||||
}
|
|
||||||
} else if (typeof resAny.error === 'string') {
|
|
||||||
apiError = resAny.error;
|
|
||||||
} else if (resAny.message) {
|
|
||||||
apiError = resAny.message;
|
|
||||||
} else if (resAny.detail) {
|
|
||||||
apiError = resAny.detail;
|
|
||||||
} else if (resAny.data) {
|
|
||||||
apiError = resAny.data.message || resAny.data.detail || resAny.data.error || apiError;
|
|
||||||
}
|
|
||||||
setShowError(apiError);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setShowError('Unable to reach hospital authentication server');
|
|
||||||
} finally {
|
|
||||||
if (!loginSuccess) setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMfaVerify = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
setShowError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await hospitalApi.verifyMfa(mfaSessionToken, mfaCode);
|
|
||||||
|
|
||||||
if (response.status === 201 || response.status === 200) {
|
|
||||||
const userToStore = response.data?.user || tempUser || {};
|
|
||||||
userToStore.mfa_enabled = true;
|
|
||||||
|
|
||||||
setLoginSuccess(true);
|
|
||||||
localStorage.setItem('teleems_auth', 'true');
|
|
||||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
|
||||||
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
|
||||||
|
|
||||||
setTimeout(() => navigate('/'), 800);
|
|
||||||
} else {
|
|
||||||
let apiError = 'Invalid verification code';
|
|
||||||
const resAny = response as any;
|
|
||||||
if (resAny.error && typeof resAny.error === 'object') {
|
|
||||||
if (Array.isArray(resAny.error.details) && resAny.error.details.length > 0) {
|
|
||||||
apiError = resAny.error.details.join('; ');
|
|
||||||
} else {
|
|
||||||
apiError = resAny.error.message || apiError;
|
|
||||||
}
|
|
||||||
} else if (typeof resAny.error === 'string') {
|
|
||||||
apiError = resAny.error;
|
|
||||||
} else if (resAny.message) {
|
|
||||||
apiError = resAny.message;
|
|
||||||
} else if (resAny.detail) {
|
|
||||||
apiError = resAny.detail;
|
|
||||||
} else if (resAny.data) {
|
|
||||||
apiError = resAny.data.message || resAny.data.detail || resAny.data.error || apiError;
|
|
||||||
}
|
|
||||||
setShowError(apiError);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setShowError('MFA verification failed. Please try again.');
|
|
||||||
} finally {
|
|
||||||
if (!loginSuccess) setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="hosp-login-page">
|
|
||||||
{/* Background effects */}
|
|
||||||
<div className="hosp-bg-grid" />
|
|
||||||
<div className="hosp-bg-orb orb-1" />
|
|
||||||
<div className="hosp-bg-orb orb-2" />
|
|
||||||
<div className="hosp-bg-orb orb-3" />
|
|
||||||
<div className="hosp-scan-line" />
|
|
||||||
|
|
||||||
{/* Floating medical icons */}
|
|
||||||
<div className="hosp-floating-icons">
|
|
||||||
<motion.div
|
|
||||||
className="float-icon fi-1"
|
|
||||||
animate={{ y: [-10, 10, -10], rotate: [0, 5, -5, 0] }}
|
|
||||||
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
|
|
||||||
>
|
|
||||||
<HeartPulse size={20} />
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="float-icon fi-2"
|
|
||||||
animate={{ y: [10, -10, 10], rotate: [0, -5, 5, 0] }}
|
|
||||||
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
|
|
||||||
>
|
|
||||||
<Stethoscope size={18} />
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="float-icon fi-3"
|
|
||||||
animate={{ y: [-8, 12, -8] }}
|
|
||||||
transition={{ duration: 7, repeat: Infinity, ease: 'easeInOut' }}
|
|
||||||
>
|
|
||||||
<Activity size={16} />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Login card */}
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={loginStep + (loginSuccess ? '-success' : '')}
|
|
||||||
initial={{ opacity: 0, y: 30, scale: 0.96 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: -20, scale: 0.96 }}
|
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className={`hosp-login-card ${loginSuccess ? 'success-glow' : ''}`}
|
|
||||||
>
|
|
||||||
{/* Card top accent */}
|
|
||||||
<div className="hosp-card-accent" />
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="hosp-login-header">
|
|
||||||
<motion.div
|
|
||||||
className="hosp-logo-container"
|
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
|
|
||||||
>
|
|
||||||
<div className="hosp-logo-ring" />
|
|
||||||
<div className="hosp-logo-inner">
|
|
||||||
<Building size={26} strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
<div className="hosp-logo-pulse" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.h1
|
|
||||||
className="hosp-title"
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
>
|
|
||||||
{loginStep === 'login' ? 'Hospital Console' : 'Verify Identity'}
|
|
||||||
</motion.h1>
|
|
||||||
<motion.p
|
|
||||||
className="hosp-subtitle"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.4 }}
|
|
||||||
>
|
|
||||||
{loginStep === 'login'
|
|
||||||
? 'Secure Clinical Operations Portal'
|
|
||||||
: 'Enter your authenticator code'}
|
|
||||||
</motion.p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Login form */}
|
|
||||||
{loginStep === 'login' ? (
|
|
||||||
<form onSubmit={handleLogin} className="hosp-form">
|
|
||||||
<motion.div
|
|
||||||
className="hosp-input-group"
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.35 }}
|
|
||||||
>
|
|
||||||
<label className="hosp-label">Hospital ID</label>
|
|
||||||
<div className="hosp-input-wrap">
|
|
||||||
<User className="hosp-input-icon" size={18} />
|
|
||||||
<input
|
|
||||||
id="hospital-username"
|
|
||||||
type="text"
|
|
||||||
className="hosp-input"
|
|
||||||
placeholder="Enter username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
<div className="hosp-input-focus-line" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="hosp-input-group"
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.45 }}
|
|
||||||
>
|
|
||||||
<label className="hosp-label">Access Key</label>
|
|
||||||
<div className="hosp-input-wrap">
|
|
||||||
<Lock className="hosp-input-icon" size={18} />
|
|
||||||
<input
|
|
||||||
id="hospital-password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
className="hosp-input"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hosp-eye-btn"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
<div className="hosp-input-focus-line" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="hosp-extras"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
>
|
|
||||||
<label className="hosp-remember">
|
|
||||||
<input type="checkbox" />
|
|
||||||
<span className="hosp-check-mark" />
|
|
||||||
Keep session active
|
|
||||||
</label>
|
|
||||||
<a href="#" className="hosp-forgot">Reset credentials?</a>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
type="submit"
|
|
||||||
className="hosp-submit-btn"
|
|
||||||
disabled={isLoading || loginSuccess}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.55 }}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
>
|
|
||||||
{loginSuccess ? (
|
|
||||||
<motion.div
|
|
||||||
className="hosp-success-check"
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300 }}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={22} />
|
|
||||||
<span>ACCESS GRANTED</span>
|
|
||||||
</motion.div>
|
|
||||||
) : isLoading ? (
|
|
||||||
<div className="hosp-loader">
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
|
||||||
>
|
|
||||||
<Cpu size={20} />
|
|
||||||
</motion.div>
|
|
||||||
<span>AUTHENTICATING...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>SECURE LOGIN</span>
|
|
||||||
<ArrowRight size={18} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
/* MFA Form */
|
|
||||||
<form onSubmit={handleMfaVerify} className="hosp-form">
|
|
||||||
<motion.div
|
|
||||||
className="hosp-input-group"
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<label className="hosp-label">TOTP Security Code</label>
|
|
||||||
<div className="hosp-input-wrap">
|
|
||||||
<KeyRound className="hosp-input-icon" size={18} />
|
|
||||||
<input
|
|
||||||
id="hospital-mfa-code"
|
|
||||||
type="text"
|
|
||||||
className="hosp-input mono"
|
|
||||||
placeholder="000 000"
|
|
||||||
maxLength={6}
|
|
||||||
value={mfaCode}
|
|
||||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="hosp-input-focus-line" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<p className="hosp-mfa-hint">
|
|
||||||
<AlertCircle size={14} />
|
|
||||||
Enter the 6-digit code from your authenticator app
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
type="submit"
|
|
||||||
className="hosp-submit-btn"
|
|
||||||
disabled={isLoading || loginSuccess}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
>
|
|
||||||
{loginSuccess ? (
|
|
||||||
<motion.div
|
|
||||||
className="hosp-success-check"
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={22} />
|
|
||||||
<span>VERIFIED</span>
|
|
||||||
</motion.div>
|
|
||||||
) : isLoading ? (
|
|
||||||
<div className="hosp-loader">
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
|
||||||
>
|
|
||||||
<Cpu size={20} />
|
|
||||||
</motion.div>
|
|
||||||
<span>VERIFYING...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ShieldCheck size={18} />
|
|
||||||
<span>VERIFY IDENTITY</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hosp-back-btn"
|
|
||||||
onClick={() => { setLoginStep('login'); setShowError(''); setMfaCode(''); }}
|
|
||||||
>
|
|
||||||
← Back to Login
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error display */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{showError && (
|
|
||||||
<motion.div
|
|
||||||
className="hosp-error-badge"
|
|
||||||
initial={{ opacity: 0, height: 0, marginTop: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto', marginTop: 16 }}
|
|
||||||
exit={{ opacity: 0, height: 0, marginTop: 0 }}
|
|
||||||
>
|
|
||||||
<ShieldAlert size={14} />
|
|
||||||
<span>{showError}</span>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Security badge */}
|
|
||||||
{!showError && (
|
|
||||||
<motion.div
|
|
||||||
className="hosp-security-badge"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.6 }}
|
|
||||||
>
|
|
||||||
<ShieldCheck size={14} />
|
|
||||||
<span>HIPAA-COMPLIANT SECURE CONNECTION</span>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="hosp-footer">
|
|
||||||
<NavLink to="/launcher" className="hosp-portal-link">
|
|
||||||
<Monitor size={14} />
|
|
||||||
VIEW ALL PORTALS
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Status indicators */}
|
|
||||||
<div className="hosp-status-bar">
|
|
||||||
<div className="hosp-status-item">
|
|
||||||
<Wifi size={12} />
|
|
||||||
<span>UPLINK ACTIVE</span>
|
|
||||||
</div>
|
|
||||||
<div className="hosp-status-dot" />
|
|
||||||
<div className="hosp-status-item">
|
|
||||||
<Activity size={12} />
|
|
||||||
<span>NODE ONLINE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HospitalLogin;
|
|
||||||
@@ -113,7 +113,7 @@ export const HospitalsNetwork: React.FC = () => {
|
|||||||
setRealHospitals(hospitalNodes);
|
setRealHospitals(hospitalNodes);
|
||||||
|
|
||||||
// Derive current issues
|
// Derive current issues
|
||||||
const newIssues = hospitalNodes.map((h: any) => {
|
const newIssues = hospitalNodes.map(h => {
|
||||||
const [available] = (h.beds || '0/0').split('/').map((n: string) => parseInt(n) || 0);
|
const [available] = (h.beds || '0/0').split('/').map((n: string) => parseInt(n) || 0);
|
||||||
if (available === 0) return { type: 'CRITICAL', msg: `${h.name}: Zero bed capacity`, hospital: h.name };
|
if (available === 0) return { type: 'CRITICAL', msg: `${h.name}: Zero bed capacity`, hospital: h.name };
|
||||||
if (available < 5) return { type: 'WARNING', msg: `${h.name}: Low bed availability`, hospital: h.name };
|
if (available < 5) return { type: 'WARNING', msg: `${h.name}: Low bed availability`, hospital: h.name };
|
||||||
@@ -582,7 +582,7 @@ export const HospitalsNetwork: React.FC = () => {
|
|||||||
onSubmit={triggerSubmit}
|
onSubmit={triggerSubmit}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
<HospitalRegistrationForm onSubmit={handleHospitalSubmit} />
|
<HospitalRegistrationForm onSubmit={handleHospitalSubmit} loading={isSubmitting} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* EDIT HOSPITAL MODAL */}
|
{/* EDIT HOSPITAL MODAL */}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
// Patient Modal states
|
// Patient Modal states
|
||||||
const [isAddPatientsModalOpen, setIsAddPatientsModalOpen] = useState(false);
|
const [isAddPatientsModalOpen, setIsAddPatientsModalOpen] = useState(false);
|
||||||
const [bulkPatients, setBulkPatients] = useState<any[]>([
|
const [bulkPatients, setBulkPatients] = useState<any[]>([
|
||||||
{ gender: 'Male', triage_level: 'RED', symptoms: [] }
|
{ gender: 'Male', triage_code: 'RED', symptoms: [] }
|
||||||
]);
|
]);
|
||||||
const [isMapPickerOpen, setIsMapPickerOpen] = useState(false);
|
const [isMapPickerOpen, setIsMapPickerOpen] = useState(false);
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
age: 0,
|
age: 0,
|
||||||
gender: 'Male',
|
gender: 'Male',
|
||||||
symptoms: [],
|
symptoms: [],
|
||||||
triage_level: 'GREEN'
|
triage_code: 'GREEN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
caller_id: JSON.parse(localStorage.getItem('teleems_user') || '{}').id || '',
|
caller_id: JSON.parse(localStorage.getItem('teleems_user') || '{}').id || '',
|
||||||
@@ -272,7 +272,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
notes: formData.notes,
|
notes: formData.notes,
|
||||||
patients: formData.patients?.map(p => ({
|
patients: formData.patients?.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
triage_level: p.triage_level || 'GREEN'
|
triage_code: p.triage_code || 'GREEN'
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
const response = await incidentsApi.createIncident(payload, token);
|
const response = await incidentsApi.createIncident(payload, token);
|
||||||
@@ -317,7 +317,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
age: 0,
|
age: 0,
|
||||||
gender: 'Male',
|
gender: 'Male',
|
||||||
symptoms: [],
|
symptoms: [],
|
||||||
triage_level: 'GREEN'
|
triage_code: 'GREEN'
|
||||||
}],
|
}],
|
||||||
caller_id: JSON.parse(localStorage.getItem('teleems_user') || '{}').id || '',
|
caller_id: JSON.parse(localStorage.getItem('teleems_user') || '{}').id || '',
|
||||||
organisationId: null
|
organisationId: null
|
||||||
@@ -386,7 +386,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
if (response.status === 200 || response.status === 201) {
|
if (response.status === 200 || response.status === 201) {
|
||||||
setIsAddPatientsModalOpen(false);
|
setIsAddPatientsModalOpen(false);
|
||||||
fetchIncidentDetails(selectedIncident.id);
|
fetchIncidentDetails(selectedIncident.id);
|
||||||
setBulkPatients([{ gender: 'Male', triage_level: 'RED', symptoms: [] }]);
|
setBulkPatients([{ gender: 'Male', triage_code: 'RED', symptoms: [] }]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Bulk add patients failed:', error);
|
console.error('Bulk add patients failed:', error);
|
||||||
@@ -767,23 +767,23 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{selectedIncident.patients.map((p, idx) => (
|
{selectedIncident.patients.map((p, idx) => (
|
||||||
<div key={idx} className="glass" style={{ padding: '16px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--card-border)', borderRadius: '12px', position: 'relative', overflow: 'hidden' }}>
|
<div key={idx} className="glass" style={{ padding: '16px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--card-border)', borderRadius: '12px', position: 'relative', overflow: 'hidden' }}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: '4px', background: (p.triage_level || p.triage_level) === 'RED' ? 'var(--alert-red)' : (p.triage_level || p.triage_level) === 'YELLOW' ? 'var(--warning-amber)' : 'var(--accent-green)' }}></div>
|
<div style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: '4px', background: (p.triage_level || p.triage_code) === 'RED' ? 'var(--alert-red)' : (p.triage_level || p.triage_code) === 'YELLOW' ? 'var(--warning-amber)' : 'var(--accent-green)' }}></div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name || 'ANONYMOUS-SUBJECT'}</div>
|
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name || 'ANONYMOUS-SUBJECT'}</div>
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.age || '?'}Y · {p.gender} · TRIAGE: {(p.triage_level || p.triage_level)?.toUpperCase()}</div>
|
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.age || '?'}Y · {p.gender} · TRIAGE: {(p.triage_level || p.triage_code)?.toUpperCase()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
fontSize: '0.6rem',
|
fontSize: '0.6rem',
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
background: (p.triage_level || p.triage_level) === 'RED' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(0,0,0,0.02)',
|
background: (p.triage_level || p.triage_code) === 'RED' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(0,0,0,0.02)',
|
||||||
color: (p.triage_level || p.triage_level) === 'RED' ? 'var(--alert-red)' : 'var(--text-primary)',
|
color: (p.triage_level || p.triage_code) === 'RED' ? 'var(--alert-red)' : 'var(--text-primary)',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: (p.triage_level || p.triage_level) === 'RED' ? 'rgba(239, 68, 68, 0.2)' : 'var(--card-border)'
|
borderColor: (p.triage_level || p.triage_code) === 'RED' ? 'rgba(239, 68, 68, 0.2)' : 'var(--card-border)'
|
||||||
}}>
|
}}>
|
||||||
CODE {(p.triage_level || p.triage_level) || 'PENDING'}
|
CODE {(p.triage_level || p.triage_code) || 'PENDING'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
@@ -1098,7 +1098,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newPatients = [...(formData.patients || [])];
|
const newPatients = [...(formData.patients || [])];
|
||||||
newPatients.push({ name: '', age: 0, gender: 'Male', symptoms: [], triage_level: 'GREEN' });
|
newPatients.push({ name: '', age: 0, gender: 'Male', symptoms: [], triage_code: 'GREEN' });
|
||||||
setFormData({ ...formData, patients: newPatients });
|
setFormData({ ...formData, patients: newPatients });
|
||||||
}}
|
}}
|
||||||
style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)', color: 'var(--accent-cyan)', padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 800, cursor: 'pointer' }}
|
style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)', color: 'var(--accent-cyan)', padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 800, cursor: 'pointer' }}
|
||||||
@@ -1168,10 +1168,10 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '6px', fontWeight: 700 }}>TRIAGE</label>
|
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '6px', fontWeight: 700 }}>TRIAGE</label>
|
||||||
<select
|
<select
|
||||||
value={patient.triage_level}
|
value={patient.triage_code}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newPatients = [...(formData.patients || [])];
|
const newPatients = [...(formData.patients || [])];
|
||||||
newPatients[index] = { ...newPatients[index], triage_level: e.target.value };
|
newPatients[index] = { ...newPatients[index], triage_code: e.target.value };
|
||||||
setFormData({ ...formData, patients: newPatients });
|
setFormData({ ...formData, patients: newPatients });
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||||
@@ -1190,7 +1190,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
value={patient.symptoms?.map((s: any) => typeof s === 'string' ? s : s?.name || '').join(', ')}
|
value={patient.symptoms?.map((s: any) => typeof s === 'string' ? s : s?.name || '').join(', ')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newPatients = [...(formData.patients || [])];
|
const newPatients = [...(formData.patients || [])];
|
||||||
newPatients[index] = { ...newPatients[index], symptoms: e.target.value.split(',').map(s => s.trim()).filter(s => s !== '').map(s => ({ name: s, duration_minutes: 0 })) };
|
newPatients[index] = { ...newPatients[index], symptoms: e.target.value.split(',').map(s => s.trim()).filter(s => s !== '') };
|
||||||
setFormData({ ...formData, patients: newPatients });
|
setFormData({ ...formData, patients: newPatients });
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||||
@@ -1320,10 +1320,10 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '6px' }}>TRIAGE CODE</label>
|
<label style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '6px' }}>TRIAGE CODE</label>
|
||||||
<select
|
<select
|
||||||
value={patient.triage_level}
|
value={patient.triage_code}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newPatients = [...bulkPatients];
|
const newPatients = [...bulkPatients];
|
||||||
newPatients[index].triage_level = e.target.value;
|
newPatients[index].triage_code = e.target.value;
|
||||||
setBulkPatients(newPatients);
|
setBulkPatients(newPatients);
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||||
@@ -1354,7 +1354,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setBulkPatients([...bulkPatients, { gender: 'Male', triage_level: 'GREEN', symptoms: [] }])}
|
onClick={() => setBulkPatients([...bulkPatients, { gender: 'Male', triage_code: 'GREEN', symptoms: [] }])}
|
||||||
style={{ padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px dashed var(--card-border)', borderRadius: '12px', color: 'var(--text-secondary)', fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer' }}
|
style={{ padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px dashed var(--card-border)', borderRadius: '12px', color: 'var(--text-secondary)', fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
+ ADD ANOTHER SUBJECT
|
+ ADD ANOTHER SUBJECT
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
.launcher-page {
|
.launcher-page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
background: var(--hull-dark-l);
|
overflow-y: auto;
|
||||||
background-color: hsl(var(--hull-dark-h), var(--hull-dark-s), 4%);
|
overflow-x: hidden;
|
||||||
color: #fff;
|
background: #020617;
|
||||||
|
color: #f8fafc;
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -26,37 +27,74 @@
|
|||||||
background: rgba(59, 130, 246, 0.4);
|
background: rgba(59, 130, 246, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Background Effects - Clean Clinical Finish */
|
/* Background Effects */
|
||||||
.launcher-bg {
|
.launcher-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: hsl(var(--hull-dark-h), var(--hull-dark-s), 2%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-grid {
|
.launcher-grid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, hsla(var(--accent-cyan-h), 100%, 50%, 0.015) 1px, transparent 1px),
|
linear-gradient(to right, rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, hsla(var(--accent-cyan-h), 100%, 50%, 0.015) 1px, transparent 1px);
|
linear-gradient(to bottom, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
||||||
background-size: 80px 80px;
|
background-size: 50px 50px;
|
||||||
mask-image: radial-gradient(circle at 0% 0%, black, transparent 100%);
|
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-blob {
|
||||||
|
position: absolute;
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.15;
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-1 {
|
||||||
|
top: -100px;
|
||||||
|
left: -100px;
|
||||||
|
background: #3b82f6;
|
||||||
|
animation: float 20s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-2 {
|
||||||
|
bottom: -100px;
|
||||||
|
right: -100px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
animation: float 25s infinite alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-3 {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #10b981;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
opacity: 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
from { transform: translate(0, 0); }
|
||||||
|
to { transform: translate(100px, 50px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.launcher-header {
|
.launcher-header {
|
||||||
position: sticky;
|
position: relative;
|
||||||
top: 0;
|
z-index: 10;
|
||||||
z-index: 100;
|
padding: 24px 40px;
|
||||||
padding: 20px 40px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid hsla(0, 0%, 100%, 0.05);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
background: hsla(220, 30%, 5%, 0.8);
|
background: rgba(2, 6, 23, 0.5);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-brand {
|
.launcher-brand {
|
||||||
@@ -189,22 +227,17 @@
|
|||||||
/* Cards */
|
/* Cards */
|
||||||
.portal-card {
|
.portal-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: hsla(220, 30%, 10%, 0.4);
|
background: rgba(15, 23, 42, 0.6);
|
||||||
border: 1px solid hsla(0, 0%, 100%, 0.05);
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
border-radius: 28px;
|
border-radius: 24px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: var(--transition-snappy);
|
transition: border-color 0.3s;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-card:hover {
|
.portal-card:hover {
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
background: hsla(220, 30%, 15%, 0.6);
|
|
||||||
transform: translateY(-8px);
|
|
||||||
box-shadow: 0 30px 60px -12px hsla(0, 0%, 0%, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-card-glow {
|
.portal-card-glow {
|
||||||
@@ -213,14 +246,14 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: radial-gradient(circle at top right, var(--accent-color), transparent 60%);
|
background: radial-gradient(circle at top right, var(--accent-color), transparent 70%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.4s;
|
transition: opacity 0.3s;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-card:hover .portal-card-glow {
|
.portal-card:hover .portal-card-glow {
|
||||||
opacity: 0.15;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-card-inner {
|
.portal-card-inner {
|
||||||
@@ -252,21 +285,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.portal-subtitle {
|
.portal-subtitle {
|
||||||
font-size: 0.65rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 950;
|
font-weight: 800;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
filter: brightness(1.2);
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-title {
|
.portal-title {
|
||||||
font-size: 1.6rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 900;
|
font-weight: 800;
|
||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-description {
|
.portal-description {
|
||||||
@@ -369,20 +399,21 @@
|
|||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.launcher-main-title { font-size: 3rem; }
|
|
||||||
.portal-grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.launcher-header { padding: 16px 20px; }
|
.launcher-main-title {
|
||||||
.launcher-actions { display: none; }
|
font-size: 2.5rem;
|
||||||
.launcher-main-title { font-size: 2.2rem; }
|
}
|
||||||
.launcher-intro { margin-bottom: 40px; }
|
.portal-grid {
|
||||||
.portal-grid { grid-template-columns: 1fr; gap: 16px; }
|
grid-template-columns: 1fr;
|
||||||
.launcher-content { padding: 40px 20px; }
|
}
|
||||||
.portal-card { padding: 24px; }
|
.launcher-header {
|
||||||
.footer-info { gap: 20px; flex-direction: column; align-items: center; text-align: center; }
|
padding: 16px 20px;
|
||||||
.footer-links { justify-content: center; width: 100%; margin-top: 24px; }
|
}
|
||||||
.launcher-footer { flex-direction: column; gap: 24px; padding: 40px 20px; }
|
.launcher-content {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.footer-info {
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,28 +61,76 @@ const PortalCard: React.FC<PortalCardProps> = ({ title, subtitle, icon: Icon, co
|
|||||||
export const PerspectiveLauncher: React.FC = () => {
|
export const PerspectiveLauncher: React.FC = () => {
|
||||||
const portals = [
|
const portals = [
|
||||||
{
|
{
|
||||||
title: 'Hospital Admin',
|
title: 'Admin Control',
|
||||||
subtitle: 'FACILITY MANAGEMENT',
|
subtitle: 'SYSTEM ADMINISTRATION',
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
color: '#3b82f6',
|
color: '#f8fafc',
|
||||||
path: '/login/hospital',
|
path: '/login/admin',
|
||||||
description: 'Full administrative control over facility configuration, department setup, and staff management.'
|
description: 'Global system configuration, user management, and infrastructure monitoring.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'ED Doctor (ERCP)',
|
title: 'Hospital Group',
|
||||||
subtitle: 'CLINICAL OPERATIONS',
|
subtitle: 'REGIONAL MANAGEMENT',
|
||||||
icon: Stethoscope,
|
icon: Building2,
|
||||||
|
color: '#3b82f6',
|
||||||
|
path: '/login/hospital-group',
|
||||||
|
description: 'Centralized oversight for multiple healthcare facilities and resource allocation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Hospital',
|
||||||
|
subtitle: 'FACILITY OPERATIONS',
|
||||||
|
icon: Building,
|
||||||
color: '#10b981',
|
color: '#10b981',
|
||||||
path: '/login/hospital',
|
path: '/login/hospital',
|
||||||
description: 'Manage active patient triage, teleconsultations, and real-time vital monitoring from the mission control.'
|
description: 'End-to-end management of emergency department operations and bed tracking.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Hospital Coordinator',
|
title: 'Provider',
|
||||||
subtitle: 'PATIENT LOGISTICS',
|
subtitle: 'CLINICAL CARE',
|
||||||
icon: Building,
|
icon: Stethoscope,
|
||||||
color: '#8b5cf6',
|
color: '#8b5cf6',
|
||||||
path: '/login/hospital',
|
path: '/login/provider',
|
||||||
description: 'Coordinate with incoming ambulances, manage bed capacity, and oversee patient documentation handovers.'
|
description: 'Dedicated interface for healthcare professionals to manage patient care.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Provider React',
|
||||||
|
subtitle: 'ACTIVE MONITORING',
|
||||||
|
icon: Activity,
|
||||||
|
color: '#f59e0b',
|
||||||
|
path: '/login/provider-react',
|
||||||
|
description: 'Real-time physiological data monitoring and emergency response triggers.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Patient',
|
||||||
|
subtitle: 'PERSONAL HEALTH',
|
||||||
|
icon: User,
|
||||||
|
color: '#ec4899',
|
||||||
|
path: '/login/patient',
|
||||||
|
description: 'Access to medical records, treatment plans, and direct communication with care teams.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Scan Centre',
|
||||||
|
subtitle: 'DIAGNOSTICS & IMAGING',
|
||||||
|
icon: Scan,
|
||||||
|
color: '#06b6d4',
|
||||||
|
path: '/login/scan-centre',
|
||||||
|
description: 'Radiology and diagnostic workflow management with high-fidelity imaging.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cart / Mobile',
|
||||||
|
subtitle: 'EMERGENCY RESPONSE',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
color: '#f43f5e',
|
||||||
|
path: '/login/cart',
|
||||||
|
description: 'On-the-go medical equipment tracking and mobile incident management.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fleet Command',
|
||||||
|
subtitle: 'TACTICAL LOGISTICS',
|
||||||
|
icon: Truck,
|
||||||
|
color: '#f59e0b',
|
||||||
|
path: '/fleet-login',
|
||||||
|
description: 'Real-time vehicle tracking, tactical dispatch, and fleet telemetry monitoring.'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
168
src/pages/fleet/FleetActiveShifts.tsx
Normal file
168
src/pages/fleet/FleetActiveShifts.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Clock, CheckCircle, Car, User, Activity, Loader2, Calendar } from 'lucide-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface ActiveShift {
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
vehicle?: {
|
||||||
|
registration_number?: string;
|
||||||
|
vehicle_type?: string;
|
||||||
|
};
|
||||||
|
driverId: string;
|
||||||
|
driver?: {
|
||||||
|
userId?: string;
|
||||||
|
type?: string;
|
||||||
|
user?: { name?: string };
|
||||||
|
};
|
||||||
|
staffId: string;
|
||||||
|
staff?: {
|
||||||
|
userId?: string;
|
||||||
|
type?: string;
|
||||||
|
user?: { name?: string };
|
||||||
|
};
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
status: string;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FleetActiveShifts: React.FC = () => {
|
||||||
|
const [shifts, setShifts] = useState<ActiveShift[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const fetchActiveShifts = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/shifts/active', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.message || 'Failed to fetch active shifts');
|
||||||
|
|
||||||
|
const data = json?.data?.data || json?.data || [];
|
||||||
|
setShifts(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching active shifts:', err);
|
||||||
|
setError(err.message || 'An error occurred while fetching shifts.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchActiveShifts();
|
||||||
|
}, [fetchActiveShifts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ color: '#F8FAFC', fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 size={13} className="spin" /> : '↺ Refresh'}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
|
||||||
|
<Loader2 size={32} color="#06B6D4" className="spin" />
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{shifts.map((shift, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={shift.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.02)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.07)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '18px 24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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' }}>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div style={{ padding: '5px 10px', background: 'rgba(16,185,129,0.1)', border: `1px solid rgba(16,185,129,0.3)`, borderRadius: 7, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Clock size={11} color="#10B981" />
|
||||||
|
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#10B981' }}>{shift.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time details */}
|
||||||
|
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: '#64748B', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Calendar size={12} /> Started: {new Date(shift.startTime).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crew Members */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
{/* 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>
|
||||||
|
<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' }}>
|
||||||
|
{shift.driver?.user?.name || 'Driver Profile'}
|
||||||
|
</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>
|
||||||
|
<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' }}>
|
||||||
|
{shift.staff?.user?.name || 'EMT/Staff Profile'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .spin{animation:spin 1s linear infinite}`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
620
src/pages/fleet/FleetAnalytics.tsx
Normal file
620
src/pages/fleet/FleetAnalytics.tsx
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Truck,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Award,
|
||||||
|
Shield,
|
||||||
|
MapPin,
|
||||||
|
Car
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
AreaChart,
|
||||||
|
Area
|
||||||
|
} from 'recharts';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
|
||||||
|
interface APIVehicle {
|
||||||
|
id: string;
|
||||||
|
registration_number: string;
|
||||||
|
chassis_number?: string | null;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
vehicle_type: string;
|
||||||
|
status?: string;
|
||||||
|
ownership_type?: string;
|
||||||
|
gps_lat?: string | number;
|
||||||
|
gps_lon?: string | number;
|
||||||
|
activeShift?: any;
|
||||||
|
activeRoster?: any;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FleetAnalytics: React.FC = () => {
|
||||||
|
const [vehicles, setVehicles] = useState<APIVehicle[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [timeRange, setTimeRange] = useState<'7D' | '30D' | 'ALL'>('ALL');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
|
const fetchVehiclesData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fleetApi.getVehicles(token);
|
||||||
|
let list: APIVehicle[] = [];
|
||||||
|
if (res?.data?.data && Array.isArray(res.data.data)) list = res.data.data;
|
||||||
|
else if (res?.data && Array.isArray(res.data)) list = res.data;
|
||||||
|
else if (Array.isArray(res)) list = res;
|
||||||
|
setVehicles(list);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch vehicles for analytics:', err);
|
||||||
|
setError('Failed to load fleet analytics data. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVehiclesData();
|
||||||
|
}, [fetchVehiclesData]);
|
||||||
|
|
||||||
|
// --- DERIVED ANALYTICS METRICS ---
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = vehicles.length;
|
||||||
|
const available = vehicles.filter(v => v.status === 'AVAILABLE').length;
|
||||||
|
const busy = vehicles.filter(v => v.status === 'BUSY').length;
|
||||||
|
const offline = vehicles.filter(v => v.status === 'OFFLINE' || (!v.status)).length;
|
||||||
|
const withActiveShift = vehicles.filter(v => v.activeShift !== null).length;
|
||||||
|
const withActiveRoster = vehicles.filter(v => v.activeRoster !== null).length;
|
||||||
|
const hasGps = vehicles.filter(v => v.gps_lat && parseFloat(v.gps_lat as string) !== 0).length;
|
||||||
|
|
||||||
|
const utilizationRate = total > 0 ? Math.round(((busy + withActiveShift) / total) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
available,
|
||||||
|
busy,
|
||||||
|
offline,
|
||||||
|
withActiveShift,
|
||||||
|
withActiveRoster,
|
||||||
|
hasGps,
|
||||||
|
utilizationRate
|
||||||
|
};
|
||||||
|
}, [vehicles]);
|
||||||
|
|
||||||
|
// Chart 1: Status Distribution
|
||||||
|
const statusChartData = useMemo(() => {
|
||||||
|
const statuses: Record<string, { count: number; color: string }> = {
|
||||||
|
AVAILABLE: { count: 0, color: '#10B981' }, // Emerald
|
||||||
|
BUSY: { count: 0, color: '#F59E0B' }, // Amber
|
||||||
|
OFFLINE: { count: 0, color: '#475569' }, // Slate
|
||||||
|
MAINTENANCE: { count: 0, color: '#EF4444' } // Red
|
||||||
|
};
|
||||||
|
|
||||||
|
vehicles.forEach(v => {
|
||||||
|
const status = (v.status || 'OFFLINE').toUpperCase();
|
||||||
|
if (statuses[status]) {
|
||||||
|
statuses[status].count += 1;
|
||||||
|
} else {
|
||||||
|
statuses['OFFLINE'].count += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(statuses).map(([name, info]) => ({
|
||||||
|
name,
|
||||||
|
value: info.count,
|
||||||
|
color: info.color
|
||||||
|
})).filter(d => d.value > 0);
|
||||||
|
}, [vehicles]);
|
||||||
|
|
||||||
|
// Chart 2: Vehicle Type Breakdown
|
||||||
|
const typeChartData = useMemo(() => {
|
||||||
|
const types: Record<string, number> = {};
|
||||||
|
vehicles.forEach(v => {
|
||||||
|
const type = v.vehicle_type || 'Unknown';
|
||||||
|
types[type] = (types[type] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const colors = ['#06B6D4', '#3B82F6', '#8B5CF6', '#EC4899', '#F43F5E'];
|
||||||
|
return Object.entries(types).map(([name, value], idx) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
color: colors[idx % colors.length]
|
||||||
|
}));
|
||||||
|
}, [vehicles]);
|
||||||
|
|
||||||
|
// Chart 3: Shift / Roster Deployment Metrics
|
||||||
|
const deploymentChartData = useMemo(() => {
|
||||||
|
let activeShiftCount = 0;
|
||||||
|
let scheduledRosterCount = 0;
|
||||||
|
let unassignedCount = 0;
|
||||||
|
|
||||||
|
vehicles.forEach(v => {
|
||||||
|
if (v.activeShift) {
|
||||||
|
activeShiftCount++;
|
||||||
|
} else if (v.activeRoster) {
|
||||||
|
scheduledRosterCount++;
|
||||||
|
} else {
|
||||||
|
unassignedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: 'Active Shift (On Duty)', value: activeShiftCount, color: '#10B981' },
|
||||||
|
{ name: 'Scheduled Roster (Planned)', value: scheduledRosterCount, color: '#8B5CF6' },
|
||||||
|
{ name: 'Unassigned Units', value: unassignedCount, color: '#F59E0B' }
|
||||||
|
];
|
||||||
|
}, [vehicles]);
|
||||||
|
|
||||||
|
// Chart 4: Brand Representation
|
||||||
|
const brandChartData = useMemo(() => {
|
||||||
|
const brands: Record<string, number> = {};
|
||||||
|
vehicles.forEach(v => {
|
||||||
|
const brand = v.brand || 'Unspecified';
|
||||||
|
brands[brand] = (brands[brand] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'];
|
||||||
|
return Object.entries(brands).map(([name, value], idx) => ({
|
||||||
|
name,
|
||||||
|
count: value,
|
||||||
|
color: colors[idx % colors.length]
|
||||||
|
}));
|
||||||
|
}, [vehicles]);
|
||||||
|
|
||||||
|
// Chart 5: Mock Fleet Mileage / Performance (Time Series)
|
||||||
|
const performanceTrendData = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ name: '06:00', trips: 2, responseTime: 8, efficiency: 94 },
|
||||||
|
{ name: '08:00', trips: 5, responseTime: 12, efficiency: 89 },
|
||||||
|
{ name: '10:00', trips: 8, responseTime: 14, efficiency: 85 },
|
||||||
|
{ name: '12:00', trips: 6, responseTime: 10, efficiency: 91 },
|
||||||
|
{ name: '14:00', trips: 9, responseTime: 15, efficiency: 82 },
|
||||||
|
{ name: '16:00', trips: 11, responseTime: 13, efficiency: 86 },
|
||||||
|
{ name: '18:00', trips: 14, responseTime: 16, efficiency: 80 },
|
||||||
|
{ name: '20:00', trips: 12, responseTime: 11, efficiency: 88 },
|
||||||
|
{ name: '22:00', trips: 7, responseTime: 9, efficiency: 93 },
|
||||||
|
{ name: '00:00', trips: 4, responseTime: 7, efficiency: 95 },
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const customTooltip = ({ active, payload }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#0F172A',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 10px 25px rgba(0,0,0,0.5)'
|
||||||
|
}}>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.75rem', fontWeight: 800, color: '#94A3B8', textTransform: 'uppercase' }}>
|
||||||
|
{payload[0].name}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '1.25rem', fontWeight: 900, color: '#fff' }}>
|
||||||
|
{payload[0].value} <span style={{ fontSize: '0.75rem', fontWeight: 500, color: '#64748B' }}>Ambulances</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 }}>
|
||||||
|
{(['7D', '30D', 'ALL'] as const).map(range => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
onClick={() => setTimeRange(range)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: timeRange === range ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : 'transparent',
|
||||||
|
color: timeRange === range ? '#fff' : '#64748B',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{range === '7D' ? '7 Days' : range === '30D' ? '30 Days' : 'All Time'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={fetchVehiclesData}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: 10,
|
||||||
|
color: '#06B6D4',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={loading ? 'spin' : ''} /> Sync Engine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: 350, gap: 16 }}>
|
||||||
|
<Loader2 size={36} className="spin" color="#06B6D4" />
|
||||||
|
<p style={{ margin: 0, fontSize: '0.85rem', color: '#64748B', fontWeight: 600 }}>Analyzing fleet database records...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '64px 24px', background: 'rgba(239,68,68,0.03)', border: '1px dashed rgba(239,68,68,0.15)', borderRadius: 16 }}>
|
||||||
|
<AlertCircle size={40} color="#EF4444" style={{ marginBottom: 12 }} />
|
||||||
|
<p style={{ color: '#EF4444', fontSize: '0.88rem', fontWeight: 600, margin: 0 }}>{error}</p>
|
||||||
|
<button onClick={fetchVehiclesData} style={{ marginTop: 16, padding: '8px 16px', background: '#EF4444', border: 'none', borderRadius: 8, color: '#fff', fontSize: '0.78rem', fontWeight: 700, cursor: 'pointer' }}>Retry Sync</button>
|
||||||
|
</div>
|
||||||
|
) : vehicles.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 }}>
|
||||||
|
<Truck size={40} color="#64748B" style={{ marginBottom: 12 }} />
|
||||||
|
<p style={{ color: '#64748B', fontSize: '0.88rem', margin: 0 }}>No active fleet assets found in the system registry.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
|
||||||
|
{/* STAT CARDS SECTION */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||||
|
|
||||||
|
{/* Total Fleet Size */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 16, padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#64748B', textTransform: 'uppercase' }}>Fleet Size</span>
|
||||||
|
<div style={{ background: 'rgba(59,130,246,0.1)', color: '#3B82F6', borderRadius: 8, padding: 6, display: 'flex' }}>
|
||||||
|
<Truck size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 900, color: '#fff' }}>{stats.total}</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#64748B', marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<TrendingUp size={11} color="#10B981" /> Fully Registered Active Units
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Utilization Rate */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 16, padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#64748B', textTransform: 'uppercase' }}>Utilization Rate</span>
|
||||||
|
<div style={{ background: 'rgba(16,185,129,0.1)', color: '#10B981', borderRadius: 8, padding: 6, display: 'flex' }}>
|
||||||
|
<Activity size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 900, color: '#fff' }}>{stats.utilizationRate}%</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#64748B', marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<TrendingUp size={11} color="#10B981" /> Ratio of On-Duty Deployments
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shift Deployment Status */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 16, padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#64748B', textTransform: 'uppercase' }}>Active Duty Shifts</span>
|
||||||
|
<div style={{ background: 'rgba(139,92,246,0.1)', color: '#8B5CF6', borderRadius: 8, padding: 6, display: 'flex' }}>
|
||||||
|
<Clock size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 900, color: '#fff' }}>{stats.withActiveShift}</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#64748B', marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<TrendingUp size={11} color="#8B5CF6" /> {stats.withActiveRoster} Planned Rosters Scheduled
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPS Telemetry Connectivity */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 16, padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#64748B', textTransform: 'uppercase' }}>GPS Core Status</span>
|
||||||
|
<div style={{ background: 'rgba(6,182,212,0.1)', color: '#06B6D4', borderRadius: 8, padding: 6, display: 'flex' }}>
|
||||||
|
<MapPin size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: 900, color: '#fff' }}>{stats.hasGps} / {stats.total}</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#10B981', marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Award size={11} /> 100% Secure Telemetry Sync
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MAIN CHARTS SECTION */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 20, flexWrap: 'wrap' }}>
|
||||||
|
|
||||||
|
{/* Chart Block 1: Deployment & Utilization */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 20, padding: 24 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 800, margin: '0 0 4px', color: '#fff' }}>Operational Deployment Dynamics</h3>
|
||||||
|
<p style={{ fontSize: '0.72rem', color: '#64748B', margin: '0 0 24px' }}>Real-time balance of actively deployed shifts vs scheduled rosters.</p>
|
||||||
|
<div style={{ height: 260 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={deploymentChartData} margin={{ top: 10, right: 10, left: -25, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||||
|
<XAxis dataKey="name" stroke="#64748B" fontSize={11} tickLine={false} />
|
||||||
|
<YAxis stroke="#64748B" fontSize={11} tickLine={false} />
|
||||||
|
<Tooltip content={customTooltip} />
|
||||||
|
<Bar dataKey="value" radius={[8, 8, 0, 0]} barSize={50}>
|
||||||
|
{deploymentChartData.map((entry, idx) => (
|
||||||
|
<Cell key={`cell-${idx}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Block 2: Fleet Status Pie Chart */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 20, padding: 24, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 800, margin: '0 0 4px', color: '#fff' }}>Availability & Health Allocation</h3>
|
||||||
|
<p style={{ fontSize: '0.72rem', color: '#64748B', margin: '0 0 24px' }}>Current real-time operational state distribution across the entire fleet.</p>
|
||||||
|
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center', gap: 20, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ width: 170, height: 170, position: 'relative' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusChartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={75}
|
||||||
|
paddingAngle={6}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{statusChartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} stroke="transparent" />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={customTooltip} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
{/* Central Text inside Pie */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<span style={{ fontSize: '1.5rem', fontWeight: 900, color: '#fff' }}>{stats.total}</span>
|
||||||
|
<span style={{ fontSize: '0.55rem', fontWeight: 700, color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Active Units</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend list */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 140 }}>
|
||||||
|
{statusChartData.map((item, idx) => {
|
||||||
|
const pct = stats.total > 0 ? Math.round((item.value / stats.total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={idx} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: item.color }} />
|
||||||
|
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: '#94A3B8' }}>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.72rem', fontWeight: 900, color: '#fff' }}>{item.value} ({pct}%)</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECOND CHARTS ROW */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.2fr', gap: 20, flexWrap: 'wrap' }}>
|
||||||
|
|
||||||
|
{/* Chart Block 3: Brand Representation */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 20, padding: 24 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 800, margin: '0 0 4px', color: '#fff' }}>Fleet Vehicle Brands</h3>
|
||||||
|
<p style={{ fontSize: '0.72rem', color: '#64748B', margin: '0 0 24px' }}>Composition of mechanical support brands currently integrated.</p>
|
||||||
|
<div style={{ height: 240 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={brandChartData}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`}
|
||||||
|
labelLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||||
|
>
|
||||||
|
{brandChartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} stroke="transparent" />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Block 4: Vehicle Type Breakdown */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 20, padding: 24 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 800, margin: '0 0 4px', color: '#fff' }}>Vehicle Type Distribution</h3>
|
||||||
|
<p style={{ fontSize: '0.72rem', color: '#64748B', margin: '0 0 24px' }}>Classification of fleet units (ALS: Advanced Life Support, BLS: Basic Life Support, etc.).</p>
|
||||||
|
<div style={{ height: 240 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={typeChartData} layout="vertical" margin={{ top: 10, right: 10, left: -25, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||||
|
<XAxis type="number" stroke="#64748B" fontSize={11} tickLine={false} />
|
||||||
|
<YAxis type="category" dataKey="name" stroke="#64748B" fontSize={11} tickLine={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" radius={[0, 6, 6, 0]} barSize={24}>
|
||||||
|
{typeChartData.map((entry, idx) => (
|
||||||
|
<Cell key={`cell-${idx}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PERFORMANCE TREND ANALYSIS (AREA CHART) */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 20, padding: 24 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 800, margin: '0 0 4px', color: '#fff' }}>Performance Trend & Mission Load Density</h3>
|
||||||
|
<p style={{ fontSize: '0.72rem', color: '#64748B', margin: '0 0 24px' }}>Hourly analysis tracking active emergencies, average dispatch response delays (min), and fleet efficiency (%).</p>
|
||||||
|
<div style={{ height: 260 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={performanceTrendData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorTrips" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#06B6D4" stopOpacity={0.4}/>
|
||||||
|
<stop offset="95%" stopColor="#06B6D4" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorResponse" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#8B5CF6" stopOpacity={0.4}/>
|
||||||
|
<stop offset="95%" stopColor="#8B5CF6" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||||
|
<XAxis dataKey="name" stroke="#64748B" fontSize={11} tickLine={false} />
|
||||||
|
<YAxis stroke="#64748B" fontSize={11} tickLine={false} />
|
||||||
|
<Tooltip contentStyle={{ background: '#0F172A', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 12 }} />
|
||||||
|
<Legend verticalAlign="top" height={36} wrapperStyle={{ fontSize: '0.75rem', fontWeight: 700 }} />
|
||||||
|
<Area type="monotone" name="Active Emergency Dispatches" dataKey="trips" stroke="#06B6D4" strokeWidth={2} fillOpacity={1} fill="url(#colorTrips)" />
|
||||||
|
<Area type="monotone" name="Response Speed (Minutes)" dataKey="responseTime" stroke="#8B5CF6" strokeWidth={2} fillOpacity={1} fill="url(#colorResponse)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DYNAMIC ACTIVE WORKFORCE INTEGRATION STATUS LIST */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 20, padding: 24 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 800, margin: 0, color: '#fff' }}>Operational Dispatch Readiness List</h3>
|
||||||
|
<p style={{ fontSize: '0.72rem', color: '#64748B', margin: '4px 0 0' }}>Real-time assignment states of active fleet units and current assigned pilots / EMTs.</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'rgba(16,185,129,0.1)', color: '#10B981', borderRadius: 8, padding: '4px 12px', fontSize: '0.7rem', fontWeight: 800, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Shield size={12} /> Live Compliance Engine
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.8rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
|
<th style={{ padding: '12px 16px', color: '#64748B', fontWeight: 700 }}>VEHICLE UNIT</th>
|
||||||
|
<th style={{ padding: '12px 16px', color: '#64748B', fontWeight: 700 }}>TYPE</th>
|
||||||
|
<th style={{ padding: '12px 16px', color: '#64748B', fontWeight: 700 }}>ASSIGNMENT STATE</th>
|
||||||
|
<th style={{ padding: '12px 16px', color: '#64748B', fontWeight: 700 }}>ACTIVE PILOT (DRIVER)</th>
|
||||||
|
<th style={{ padding: '12px 16px', color: '#64748B', fontWeight: 700 }}>ACTIVE STAFF (EMT)</th>
|
||||||
|
<th style={{ padding: '12px 16px', color: '#64748B', fontWeight: 700 }}>STATUS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{vehicles.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 || '';
|
||||||
|
const staffName = v.activeShift?.staff?.user?.name || v.activeRoster?.staff?.user?.name || '—';
|
||||||
|
|
||||||
|
const deploymentState = v.activeShift
|
||||||
|
? { label: 'ON ACTIVE SHIFT', bg: 'rgba(16,185,129,0.1)', color: '#10B981' }
|
||||||
|
: v.activeRoster
|
||||||
|
? { label: 'SCHEDULED ROSTER', bg: 'rgba(139,92,246,0.1)', color: '#8B5CF6' }
|
||||||
|
: { label: 'UNASSIGNED IDLE', bg: 'rgba(255,255,255,0.04)', color: '#64748B' };
|
||||||
|
|
||||||
|
const statusColor = v.status === 'AVAILABLE'
|
||||||
|
? '#10B981'
|
||||||
|
: v.status === 'BUSY'
|
||||||
|
? '#F59E0B'
|
||||||
|
: '#475569';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={v.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', transition: 'background 0.2s' }} className="table-row">
|
||||||
|
<td style={{ padding: '16px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Car size={14} color="#06B6D4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 800, color: '#fff' }}>{v.registration_number}</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#64748B' }}>{v.brand} {v.model}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<span style={{ fontSize: '0.65rem', fontWeight: 800, color: '#06B6D4', background: 'rgba(6,182,212,0.12)', padding: '2px 8px', borderRadius: 6 }}>
|
||||||
|
{v.vehicle_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<span style={{ fontSize: '0.65rem', fontWeight: 800, background: deploymentState.bg, color: deploymentState.color, padding: '3px 8px', borderRadius: 6 }}>
|
||||||
|
{deploymentState.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
{driverName !== '—' ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, color: '#E2E8F0' }}>{driverName}</div>
|
||||||
|
{driverPhone && <div style={{ fontSize: '0.68rem', color: '#64748B' }}>{driverPhone}</div>}
|
||||||
|
</div>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<span style={{ fontWeight: 700, color: '#E2E8F0' }}>{staffName}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: statusColor }} />
|
||||||
|
<span style={{ fontWeight: 800, color: statusColor }}>{v.status || 'OFFLINE'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
.spin { animation: spin 1s linear infinite; }
|
||||||
|
.table-row:hover { background: rgba(255,255,255,0.015); }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,207 +1,562 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus, Search, Truck, X, Loader2, CheckCircle,
|
||||||
Search,
|
AlertCircle, ChevronDown, Edit2
|
||||||
Filter,
|
|
||||||
Truck,
|
|
||||||
FileText,
|
|
||||||
Wrench,
|
|
||||||
Calendar,
|
|
||||||
AlertTriangle,
|
|
||||||
ExternalLink,
|
|
||||||
ChevronRight,
|
|
||||||
ShieldCheck,
|
|
||||||
Fuel,
|
|
||||||
Gauge
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card } from '../../components/Common';
|
|
||||||
|
interface Station {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Vehicle {
|
interface Vehicle {
|
||||||
id: string;
|
id: string;
|
||||||
number: string;
|
registration_number: string;
|
||||||
type: 'ALS' | 'BLS' | 'TRANSPORT';
|
vehicle_type: string;
|
||||||
model: string;
|
brand?: string;
|
||||||
station: string;
|
model?: string;
|
||||||
status: 'ACTIVE' | 'MAINTENANCE' | 'BREAKDOWN' | 'OFF_DUTY';
|
chassis_number?: string;
|
||||||
docs: {
|
station_id?: string;
|
||||||
rc: string;
|
status?: string;
|
||||||
fc: string;
|
|
||||||
insurance: string;
|
|
||||||
permit: string;
|
|
||||||
};
|
|
||||||
lastService: string;
|
|
||||||
nextService: string;
|
|
||||||
fuel: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_FLEET: Vehicle[] = [
|
interface VehicleForm {
|
||||||
{ id: 'V-001', number: 'KA 01 MG 2341', type: 'ALS', model: 'Force Traveller 2024', station: 'ALPHA-NODE-01', status: 'ACTIVE', docs: { rc: 'VALID', fc: 'EXPIRING_SOON', insurance: 'VALID', permit: 'VALID' }, lastService: '2026-04-10', nextService: '2026-07-10', fuel: 85 },
|
registration_number: string;
|
||||||
{ id: 'V-002', number: 'KA 51 BH 9921', type: 'BLS', model: 'Tata Winger 2023', station: 'BETA-HUB-04', status: 'MAINTENANCE', docs: { rc: 'VALID', fc: 'VALID', insurance: 'VALID', permit: 'VALID' }, lastService: '2026-05-01', nextService: '2026-08-01', fuel: 42 },
|
vehicle_type: string;
|
||||||
{ id: 'V-003', number: 'KA 03 AA 1122', type: 'ALS', model: 'Force Traveller 2023', station: 'ALPHA-NODE-01', status: 'BREAKDOWN', docs: { rc: 'VALID', fc: 'VALID', insurance: 'EXPIRING_SOON', permit: 'VALID' }, lastService: '2026-02-15', nextService: '2026-05-15', fuel: 0 },
|
chassis_number: string;
|
||||||
{ id: 'V-004', number: 'KA 05 MN 5678', type: 'TRANSPORT', model: 'Maruti Eeco 2022', station: 'GAMMA-STATION-02', status: 'ACTIVE', docs: { rc: 'VALID', fc: 'VALID', insurance: 'VALID', permit: 'VALID' }, lastService: '2026-03-20', nextService: '2026-06-20', fuel: 92 },
|
brand: string;
|
||||||
];
|
model: string;
|
||||||
|
station_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VEHICLE_TYPES = ['ALS', 'BLS', 'TRANSPORT', 'ICU', 'NEONATAL'];
|
||||||
|
|
||||||
|
const EMPTY_FORM: VehicleForm = {
|
||||||
|
registration_number: '',
|
||||||
|
vehicle_type: 'ALS',
|
||||||
|
chassis_number: '',
|
||||||
|
brand: '',
|
||||||
|
model: '',
|
||||||
|
station_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid rgba(15, 23, 42, 0.08)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px',
|
||||||
|
boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: '#475569',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '1px',
|
||||||
|
marginBottom: '6px',
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #CBD5E1',
|
||||||
|
padding: '11px 14px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
cursor: 'pointer',
|
||||||
|
appearance: 'none',
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
export const FleetAssets: React.FC = () => {
|
export const FleetAssets: React.FC = () => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
|
const [selectedStationFilter, setSelectedStationFilter] = useState('');
|
||||||
|
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||||
|
const [stations, setStations] = useState<Station[]>([]);
|
||||||
|
const [stationsLoading, setStationsLoading] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [form, setForm] = useState<VehicleForm>(EMPTY_FORM);
|
||||||
|
const [editForm, setEditForm] = useState<VehicleForm>(EMPTY_FORM);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
|
const showToast = (type: 'success' | 'error', message: string) => {
|
||||||
|
setToast({ type, message });
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
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;
|
||||||
|
else if (Array.isArray(json)) list = json;
|
||||||
|
setStations(list);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch stations:', e);
|
||||||
|
} finally {
|
||||||
|
setStationsLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch vehicles:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, selectedStationFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchVehicles(); fetchStations(); }, [fetchVehicles, fetchStations]);
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (v: Vehicle) => {
|
||||||
|
setEditingVehicle(v);
|
||||||
|
setEditForm({
|
||||||
|
registration_number: v.registration_number || '',
|
||||||
|
vehicle_type: v.vehicle_type || 'ALS',
|
||||||
|
chassis_number: v.chassis_number || '',
|
||||||
|
brand: v.brand || '',
|
||||||
|
model: v.model || '',
|
||||||
|
station_id: v.station_id || '',
|
||||||
|
});
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingVehicle) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
registration_number: editForm.registration_number,
|
||||||
|
vehicle_type: editForm.vehicle_type,
|
||||||
|
station_id: editForm.station_id || undefined,
|
||||||
|
chassis_number: editForm.chassis_number || undefined,
|
||||||
|
brand: editForm.brand,
|
||||||
|
model: editForm.model,
|
||||||
|
};
|
||||||
|
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) {
|
||||||
|
showToast('success', 'Vehicle updated successfully!');
|
||||||
|
setShowEditModal(false);
|
||||||
|
setEditingVehicle(null);
|
||||||
|
fetchVehicles();
|
||||||
|
} else {
|
||||||
|
showToast('error', json?.message || `Error ${res.status}: Failed to update vehicle.`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err?.message || 'Network error.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.station_id) {
|
||||||
|
showToast('error', 'Please select a station.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
registration_number: form.registration_number,
|
||||||
|
vehicle_type: form.vehicle_type,
|
||||||
|
station_id: form.station_id,
|
||||||
|
chassis_number: form.chassis_number || undefined,
|
||||||
|
brand: form.brand,
|
||||||
|
model: form.model,
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err?.message || 'Network error. Please check your connection.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = vehicles.filter(v =>
|
||||||
|
v.registration_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
v.brand?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
v.model?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStatusColor = (status?: string) => {
|
||||||
|
if (status === 'ACTIVE') return { bg: 'rgba(34,197,94,0.1)', border: 'rgba(34,197,94,0.2)', color: '#22C55E' };
|
||||||
|
if (status === 'BREAKDOWN') return { bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.2)', color: '#EF4444' };
|
||||||
|
return { bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.2)', color: '#F59E0B' };
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fleet-assets animate-in fade-in duration-500">
|
<div style={{ color: '#0F172A', position: 'relative' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
<AnimatePresence>
|
||||||
<div className="glass" style={{ padding: '4px 16px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
{toast && (
|
||||||
<Search size={16} style={{ opacity: 0.5 }} />
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20, x: '-50%' }}
|
||||||
|
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||||
|
exit={{ opacity: 0, y: -20, x: '-50%' }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', top: '24px', left: '50%', zIndex: 9999,
|
||||||
|
padding: '14px 24px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '10px',
|
||||||
|
background: toast.type === 'success' ? '#ECFDF5' : '#FEF2F2',
|
||||||
|
border: `1px solid ${toast.type === 'success' ? '#10B981' : '#EF4444'}`,
|
||||||
|
color: toast.type === 'success' ? '#065F46' : '#991B1B',
|
||||||
|
fontWeight: 600, fontSize: '0.875rem',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
|
||||||
|
{toast.message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModal && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(15, 23, 42, 0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.92, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.92, opacity: 0 }}
|
||||||
|
style={{ background: '#FFFFFF', borderRadius: '20px', padding: '32px', width: '560px', maxWidth: '95vw', boxShadow: '0 20px 50px rgba(0,0,0,0.1)' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.4rem', fontWeight: 900, color: '#0F172A', margin: 0 }}>Register New Vehicle</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#64748B', margin: '4px 0 0' }}>Add an ambulance to your fleet</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowModal(false)} style={{ background: '#F1F5F9', border: 'none', borderRadius: '8px', padding: '8px', color: '#475569', cursor: 'pointer' }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Brand *</label>
|
||||||
|
<input style={inputStyle} required value={form.brand} onChange={e => setForm(f => ({ ...f, brand: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Model *</label>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
||||||
|
<button type="button" onClick={() => setShowModal(false)} style={{ flex: 1, padding: '13px', background: '#F1F5F9', border: 'none', borderRadius: '12px', color: '#475569', cursor: 'pointer', fontWeight: 600 }}>Cancel</button>
|
||||||
|
<button type="submit" disabled={submitting} style={{ flex: 2, padding: '13px', background: '#0F172A', border: 'none', borderRadius: '12px', color: '#fff', cursor: 'pointer', fontWeight: 700 }}>{submitting ? 'Registering...' : 'REGISTER VEHICLE'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showEditModal && editingVehicle && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(15, 23, 42, 0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowEditModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.92, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.92, opacity: 0 }}
|
||||||
|
style={{ background: '#FFFFFF', borderRadius: '20px', padding: '32px', width: '560px', maxWidth: '95vw', boxShadow: '0 20px 50px rgba(0,0,0,0.1)' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.4rem', fontWeight: 900, color: '#0F172A', margin: 0 }}>Edit Vehicle</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#64748B', margin: '4px 0 0' }}>Update details for {editingVehicle.registration_number}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowEditModal(false)} style={{ background: '#F1F5F9', border: 'none', borderRadius: '8px', padding: '8px', color: '#475569', cursor: 'pointer' }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Brand *</label>
|
||||||
|
<input style={inputStyle} required value={editForm.brand} onChange={e => setEditForm(f => ({ ...f, brand: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Model *</label>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
||||||
|
<button type="button" onClick={() => setShowEditModal(false)} style={{ flex: 1, padding: '13px', background: '#F1F5F9', border: 'none', borderRadius: '12px', color: '#475569', cursor: 'pointer', fontWeight: 600 }}>Cancel</button>
|
||||||
|
<button type="submit" disabled={submitting} style={{ flex: 2, padding: '13px', background: '#0F172A', border: 'none', borderRadius: '12px', color: '#fff', cursor: 'pointer', fontWeight: 700 }}>{submitting ? 'Updating...' : 'UPDATE VEHICLE'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isSearchFocused ? '1px solid #06B6D4' : '1px solid #CBD5E1',
|
||||||
|
boxShadow: isSearchFocused ? '0 0 0 3px rgba(6, 182, 212, 0.15)' : '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
width: '280px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<Search size={16} color={isSearchFocused ? "#06B6D4" : "#64748B"} style={{ transition: 'color 0.2s' }} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by vehicle number or model..."
|
placeholder="Search vehicles..."
|
||||||
style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.875rem', padding: '8px 0', width: '300px', outline: 'none' }}
|
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
className="stations-search-input"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
paddingRight: searchQuery ? '24px' : '0'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '12px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#94A3B8',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '2px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#475569'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#94A3B8'}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<select
|
||||||
|
value={selectedStationFilter}
|
||||||
|
onChange={(e) => setSelectedStationFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #CBD5E1',
|
||||||
|
padding: '11px 36px 11px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
appearance: 'none',
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
minWidth: '200px',
|
||||||
|
boxShadow: '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">📁 All Stations</option>
|
||||||
|
{stations.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>📍 {s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
color="#64748B"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '12px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-icon glass"><Filter size={18} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
|
<button
|
||||||
|
onClick={openModal}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '11px 22px', background: '#0F172A', border: 'none', borderRadius: '12px', color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
<Plus size={18} /> REGISTER NEW VEHICLE
|
<Plus size={18} /> REGISTER NEW VEHICLE
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '24px' }}>
|
{loading && (
|
||||||
{/* Fleet Inventory Grid */}
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '200px', color: '#64748B' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<Loader2 size={24} className="spin" />
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
|
</div>
|
||||||
{MOCK_FLEET.map((v) => (
|
)}
|
||||||
<Card
|
|
||||||
|
{!loading && filtered.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 && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
|
||||||
|
{filtered.map(v => {
|
||||||
|
const sc = getStatusColor(v.status);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
key={v.id}
|
key={v.id}
|
||||||
onClick={() => setSelectedVehicle(v)}
|
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
|
||||||
style={{
|
style={{ ...cardStyle, borderLeft: `4px solid ${sc.color}` }}
|
||||||
cursor: 'pointer',
|
|
||||||
border: selectedVehicle?.id === v.id ? '2px solid var(--accent-cyan)' : '1px solid var(--card-border)',
|
|
||||||
background: selectedVehicle?.id === v.id ? 'rgba(59, 130, 246, 0.05)' : 'rgba(15, 23, 42, 0.4)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: 'var(--accent-cyan)', marginBottom: '4px' }}>{v.type} UNIT • {v.id}</div>
|
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#64748B', marginBottom: '4px' }}>{v.vehicle_type} UNIT</div>
|
||||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 800 }}>{v.number}</h3>
|
<h3 style={{ fontSize: '1rem', fontWeight: 800, color: '#0F172A', margin: 0 }}>{v.registration_number}</h3>
|
||||||
<div style={{ fontSize: '0.75rem', opacity: 0.5 }}>{v.model}</div>
|
<div style={{ fontSize: '0.78rem', color: '#475569', marginTop: '4px' }}>{v.brand} {v.model}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{ padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 900, background: sc.bg, color: sc.color }}>{v.status}</div>
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
background: v.status === 'ACTIVE' ? 'rgba(34, 197, 94, 0.1)' : v.status === 'BREAKDOWN' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(245, 158, 11, 0.1)',
|
|
||||||
color: v.status === 'ACTIVE' ? '#22C55E' : v.status === 'BREAKDOWN' ? '#EF4444' : '#F59E0B',
|
|
||||||
border: `1px solid ${v.status === 'ACTIVE' ? 'rgba(34, 197, 94, 0.2)' : v.status === 'BREAKDOWN' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(245, 158, 11, 0.2)'}`
|
|
||||||
}}>
|
|
||||||
{v.status}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button
|
||||||
|
onClick={() => openEditModal(v)}
|
||||||
<div style={{ display: 'flex', gap: '20px', marginBottom: '16px' }}>
|
style={{ width: '100%', padding: '9px', background: '#F1F5F9', border: 'none', borderRadius: '8px', color: '#0F172A', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer' }}
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '0.6rem', opacity: 0.5, textTransform: 'uppercase', marginBottom: '4px' }}>Fuel Level</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ flex: 1, height: '4px', background: 'rgba(255,255,255,0.1)', borderRadius: '2px', overflow: 'hidden' }}>
|
|
||||||
<div style={{ width: `${v.fuel}%`, height: '100%', background: v.fuel < 25 ? '#EF4444' : '#22C55E' }}></div>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 700 }}>{v.fuel}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '16px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<div className={v.docs.fc === 'VALID' ? 'status-dot-green' : 'status-pulse-amber'} title="Fitness Certificate"></div>
|
|
||||||
<div className={v.docs.insurance === 'VALID' ? 'status-dot-green' : 'status-pulse-amber'} title="Insurance"></div>
|
|
||||||
<div className={v.docs.permit === 'VALID' ? 'status-dot-green' : 'status-pulse-amber'} title="Ambulance Permit"></div>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '0.7rem', opacity: 0.5 }}>{v.station}</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detailed Inspector Panel */}
|
|
||||||
<div style={{ position: 'sticky', top: '0' }}>
|
|
||||||
{selectedVehicle ? (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={selectedVehicle.id}
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
>
|
>
|
||||||
<Card title="Asset Intelligence" subtitle={`Detailed diagnostics for ${selectedVehicle.number}`}>
|
EDIT DETAILS
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
</button>
|
||||||
{/* Critical Document Status */}
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: '0.75rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--accent-cyan)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<FileText size={14} /> Document Vault
|
|
||||||
</h4>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{[
|
|
||||||
{ label: 'Registration (RC)', status: selectedVehicle.docs.rc, expiry: '2030-12-15' },
|
|
||||||
{ label: 'Fitness (FC)', status: selectedVehicle.docs.fc, expiry: '2026-06-01' },
|
|
||||||
{ label: 'Insurance Policy', status: selectedVehicle.docs.insurance, expiry: '2026-05-15' },
|
|
||||||
{ label: 'Ambulance Permit', status: selectedVehicle.docs.permit, expiry: '2026-09-20' },
|
|
||||||
].map((doc, idx) => (
|
|
||||||
<div key={idx} style={{ padding: '12px', borderRadius: '8px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.8125rem', fontWeight: 600 }}>{doc.label}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.5 }}>Expires: {doc.expiry}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ color: doc.status === 'VALID' ? '#22C55E' : '#F59E0B', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
||||||
{doc.status === 'VALID' ? <ShieldCheck size={16} /> : <AlertTriangle size={16} />}
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 900 }}>{doc.status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Maintenance History */}
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: '0.75rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--accent-cyan)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<Wrench size={14} /> Service Records
|
|
||||||
</h4>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
<div style={{ padding: '16px', borderRadius: '12px', background: 'rgba(59, 130, 246, 0.05)', border: '1px solid rgba(59, 130, 246, 0.1)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 700 }}>Upcoming Service</span>
|
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--accent-cyan)' }}>{selectedVehicle.nextService}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', opacity: 0.6 }}>Scheduled for: Engine Oil change, Brake pad inspection, and AC filter cleaning.</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '12px', borderRadius: '8px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<div style={{ fontSize: '0.75rem' }}>Last Major Service</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 700 }}>{selectedVehicle.lastService}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
|
||||||
<button className="btn-ghost" style={{ width: '100%', fontSize: '0.75rem' }}>VIEW ALL RECORDS</button>
|
|
||||||
<button className="btn-primary" style={{ width: '100%', fontSize: '0.75rem' }}>LOG MAINTENANCE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
);
|
||||||
) : (
|
})}
|
||||||
<div className="glass" style={{ padding: '40px', borderRadius: '16px', textAlign: 'center', border: '1px dashed rgba(255,255,255,0.1)' }}>
|
|
||||||
<div style={{ width: '48px', height: '48px', borderRadius: '50%', background: 'rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px', color: 'var(--accent-cyan)' }}>
|
|
||||||
<Truck size={24} />
|
|
||||||
</div>
|
|
||||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '8px' }}>No Asset Selected</h3>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>Select a vehicle from the fleet inventory to view tactical diagnostics, documents, and service history.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .spin { animation: spin 1s linear infinite; }`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
389
src/pages/fleet/FleetOperator.css
Normal file
389
src/pages/fleet/FleetOperator.css
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
/* ─── FLEET OPERATOR LIGHT THEME OVERRIDES ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/* 1. Main Dashboard Shell Background & Text Colors */
|
||||||
|
.fleet-dashboard-container {
|
||||||
|
background: #F8FAFC !important;
|
||||||
|
/* Elegant light background */
|
||||||
|
color: #1E293B !important;
|
||||||
|
/* Deep slate gray for body text */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Top Navigation Bar */
|
||||||
|
.fleet-dashboard-header {
|
||||||
|
background: rgba(255, 255, 255, 0.85) !important;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||||
|
backdrop-filter: blur(12px) !important;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.02) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-dashboard-header h1 {
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-dashboard-header .desc,
|
||||||
|
.fleet-dashboard-header div[style*="color: #94A3B8"],
|
||||||
|
.fleet-dashboard-header div[style*="color:#94A3B8"] {
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Search Bar and Wrapper in Header */
|
||||||
|
.fleet-search-container,
|
||||||
|
div[style*="background: rgba(255, 255, 255, 0.03)"][style*="border: 1px solid rgba(255, 255, 255, 0.06)"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.03)"][style*="border:1px solid rgba(255,255,255,0.06)"] {
|
||||||
|
background: #F1F5F9 !important;
|
||||||
|
border: 1px solid #CBD5E1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-search-container input,
|
||||||
|
input[placeholder*="Search"] {
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-search-container input::placeholder,
|
||||||
|
input[placeholder*="Search"]::placeholder {
|
||||||
|
color: #94A3B8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. Action Buttons in Header */
|
||||||
|
.fleet-header-btn,
|
||||||
|
button[style*="background: rgba(255, 255, 255, 0.03)"][style*="border: 1px solid rgba(255, 255, 255, 0.06)"],
|
||||||
|
button[style*="background:rgba(255, 255,255,0.03)"][style*="border:1px solid rgba(255,255,255,0.06)"] {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border: 1px solid #E2E8F0 !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-header-btn:hover {
|
||||||
|
background: #F8FAFC !important;
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical divider in header */
|
||||||
|
.fleet-header-divider {
|
||||||
|
background: #E2E8F0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile username & user details in header */
|
||||||
|
.fleet-profile-name,
|
||||||
|
.fleet-profile-title,
|
||||||
|
div[style*="color: #fff"][style*="font-size: 0.8rem"],
|
||||||
|
div[style*="color:#fff"][style*="font-size:0.8rem"],
|
||||||
|
div[style*="font-size: 0.8rem"][style*="color: #fff"],
|
||||||
|
div[style*="font-size:0.8rem"][style*="color:#fff"] {
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. Sub-page Tab Toggle Selection Pill Containers */
|
||||||
|
div[style*="background: rgba(255, 255, 255, 0.03)"][style*="border: 1px solid rgba(255, 255, 255, 0.06)"][style*="padding: 4px"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.03)"][style*="border:1px solid rgba(255,255,255,0.06)"][style*="padding:4px"],
|
||||||
|
div[style*="background: rgba(255, 255, 255, 0.03)"][style*="border: 1px solid rgba(255, 255, 255, 0.06)"][style*="padding: 3px"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.03)"][style*="border:1px solid rgba(255,255,255,0.06)"][style*="padding:3px"] {
|
||||||
|
background: #E2E8F0 !important;
|
||||||
|
border: 1px solid #CBD5E1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unselected buttons in Tab Toggles */
|
||||||
|
div[style*="background: rgba(255, 255, 255, 0.03)"] button[style*="background: transparent"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.03)"] button[style*="background:transparent"],
|
||||||
|
div[style*="background: rgba(255, 255, 255, 0.03)"] button:not([style*="background: linear-gradient"]),
|
||||||
|
div[style*="background:rgba(255,255,255,0.03)"] button:not([style*="background:linear-gradient"]) {
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6. Cards and Main Content Panels (Overriding semi-transparent dark blocks) */
|
||||||
|
div[style*="background: rgba(255, 255, 255, 0.03)"][style*="border: 1px solid rgba(255, 255, 255, 0.08)"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.03)"][style*="border:1px solid rgba(255,255,255,0.08)"],
|
||||||
|
div[style*="background: rgba(255, 255, 255, 0.02)"][style*="border: 1px solid rgba(255, 255, 255, 0.07)"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.02)"][style*="border:1px solid rgba(255,255,255,0.07)"],
|
||||||
|
div[style*="background: rgba(255, 255, 255, 0.01)"][style*="border: 1px solid rgba(255, 255, 255, 0.08)"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.01)"][style*="border:1px solid rgba(255,255,255,0.08)"],
|
||||||
|
div[style*="background: rgba(255,255,255,0.03)"][style*="border: 1px solid rgba(255,255,255,0.07)"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.03)"][style*="border:1px solid rgba(255,255,255,0.07)"],
|
||||||
|
div[style*="background: rgba(255,255,255,0.02)"][style*="border: 1px solid rgba(255,255,255,0.06)"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.02)"][style*="border:1px solid rgba(255,255,255,0.06)"],
|
||||||
|
.glass {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.03), 0 2px 8px rgba(15, 23, 42, 0.01) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card titles inside components */
|
||||||
|
.fleet-dashboard-content h1,
|
||||||
|
.fleet-dashboard-content h2,
|
||||||
|
.fleet-dashboard-content h3,
|
||||||
|
.fleet-dashboard-content h4,
|
||||||
|
.fleet-dashboard-content .card-title {
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overriding white texts to clean slate-dark */
|
||||||
|
span[style*="color: #fff"],
|
||||||
|
span[style*="color:#fff"],
|
||||||
|
div[style*="color: #fff"],
|
||||||
|
div[style*="color:#fff"],
|
||||||
|
h3[style*="color: #fff"],
|
||||||
|
h3[style*="color:#fff"],
|
||||||
|
h1[style*="color: #fff"],
|
||||||
|
h1[style*="color:#fff"] {
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary descriptions */
|
||||||
|
div[style*="color: #E2E8F0"],
|
||||||
|
div[style*="color:#E2E8F0"],
|
||||||
|
span[style*="color: #E2E8F0"],
|
||||||
|
span[style*="color:#E2E8F0"],
|
||||||
|
div[style*="color: #F1F5F9"],
|
||||||
|
div[style*="color:#F1F5F9"] {
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Muted labels & dates */
|
||||||
|
div[style*="color: #94A3B8"],
|
||||||
|
div[style*="color:#94A3B8"],
|
||||||
|
span[style*="color: #94A3B8"],
|
||||||
|
span[style*="color:#94A3B8"],
|
||||||
|
p[style*="color: #94A3B8"],
|
||||||
|
p[style*="color:#94A3B8"],
|
||||||
|
div[style*="color: #64748B"],
|
||||||
|
div[style*="color:#64748B"] {
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 7. Inner Stats Grid and Blocks (e.g. Station detail stats cards) */
|
||||||
|
div[style*="background: #040B16"],
|
||||||
|
div[style*="background:#040B16"] {
|
||||||
|
background: #F1F5F9 !important;
|
||||||
|
border-color: #CBD5E1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[style*="background: #040B16"] div[style*="color: #fff"],
|
||||||
|
div[style*="background:#040B16"] div[style*="color:#fff"] {
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Separator lines in cards */
|
||||||
|
div[style*="border-bottom: 1px solid rgba(255,255,255,0.04)"],
|
||||||
|
div[style*="border-bottom:1px solid rgba(255,255,255,0.04)"],
|
||||||
|
div[style*="border-top: 1px solid rgba(255,255,255,0.04)"],
|
||||||
|
div[style*="border-top:1px solid rgba(255,255,255,0.04)"],
|
||||||
|
div[style*="border-top: 1px solid rgba(255,255,255,0.06)"],
|
||||||
|
div[style*="border-top:1px solid rgba(255,255,255,0.06)"] {
|
||||||
|
border-color: #E2E8F0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 8. Forms & Inputs */
|
||||||
|
/* Field Labels */
|
||||||
|
label[style*="color: #64748B"],
|
||||||
|
label[style*="color:#64748B"],
|
||||||
|
label[style*="color: #475569"],
|
||||||
|
label[style*="color:#475569"] {
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Fields and Select Dropdowns */
|
||||||
|
input[style*="background: rgba(255, 255, 255, 0.04)"],
|
||||||
|
input[style*="background:rgba(255,255,255,0.04)"],
|
||||||
|
select[style*="background: rgba(255, 255, 255, 0.04)"],
|
||||||
|
select[style*="background:rgba(255,255,255,0.04)"],
|
||||||
|
textarea[style*="background: rgba(255, 255, 255, 0.04)"],
|
||||||
|
textarea[style*="background:rgba(255,255,255,0.04)"] {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border: 1px solid #CBD5E1 !important;
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[style*="background: rgba(255, 255, 255, 0.04)"]:focus,
|
||||||
|
select[style*="background: rgba(255, 255, 255, 0.04)"]:focus,
|
||||||
|
textarea[style*="background: rgba(255, 255, 255, 0.04)"]:focus {
|
||||||
|
border-color: #06B6D4 !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select options backgrounds */
|
||||||
|
option[style*="background: #0F172A"],
|
||||||
|
option[style*="background:#0F172A"],
|
||||||
|
option[style*="background: #0D1526"],
|
||||||
|
option[style*="background:#0D1526"] {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optgroups */
|
||||||
|
optgroup {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 9. Secondary Buttons (e.g. Cancel, Edit, Sync, etc) */
|
||||||
|
button[style*="background: rgba(255, 255, 255, 0.04)"],
|
||||||
|
button[style*="background:rgba(255,255,255,0.04)"],
|
||||||
|
button[style*="background: rgba(255,255,255,0.03)"],
|
||||||
|
button[style*="background:rgba(255,255,255,0.03)"] {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border: 1px solid #CBD5E1 !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[style*="background: rgba(255, 255, 255, 0.04)"]:hover,
|
||||||
|
button[style*="background:rgba(255,255,255,0.04)"]:hover {
|
||||||
|
background: #F8FAFC !important;
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 10. Modals */
|
||||||
|
/* Background backdrop layer */
|
||||||
|
div[style*="background: rgba(0,0,0,0.7)"],
|
||||||
|
div[style*="background:rgba(0,0,0,0.7)"],
|
||||||
|
div[style*="background: rgba(0,0,0,0.75)"],
|
||||||
|
div[style*="background:rgba(0,0,0,0.75)"] {
|
||||||
|
background: rgba(15, 23, 42, 0.3) !important;
|
||||||
|
backdrop-filter: blur(8px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal box wrapper styling */
|
||||||
|
div[style*="background: #0F172A"][style*="border: 1px solid rgba(255,255,255,0.1)"],
|
||||||
|
div[style*="background:#0F172A"][style*="border:1px solid rgba(255,255,255,0.1)"],
|
||||||
|
div[style*="background: #0D1526"][style*="border: 1px solid rgba(255,255,255,0.1)"],
|
||||||
|
div[style*="background:#0D1526"][style*="border:1px solid rgba(255,255,255,0.1)"] {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||||
|
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12) !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Inner Header */
|
||||||
|
div[style*="background: linear-gradient(135deg,rgba(6,182,212,0.12),transparent)"],
|
||||||
|
div[style*="background:linear-gradient(135deg,rgba(6,182,212,0.12),transparent)"] {
|
||||||
|
background: linear-gradient(135deg, rgba(6, 182, 212, 0.06), transparent) !important;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal close button */
|
||||||
|
div[style*="background: #0F172A"] button[style*="background: rgba(255, 255, 255, 0.05)"],
|
||||||
|
div[style*="background:#0F172A"] button[style*="background:rgba(255,255,255,0.05)"],
|
||||||
|
div[style*="background: #0D1526"] button[style*="background: rgba(255, 255, 255, 0.06)"],
|
||||||
|
div[style*="background:#0D1526"] button[style*="background:rgba(255,255,255,0.06)"] {
|
||||||
|
background: #F1F5F9 !important;
|
||||||
|
border: 1px solid #E2E8F0 !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS input coordination section in modal */
|
||||||
|
div[style*="background: rgba(255,255,255,0.03)"][style*="border: 1px solid rgba(255,255,255,0.06)"],
|
||||||
|
div[style*="background:rgba(255,255,255,0.03)"][style*="border:1px solid rgba(255,255,255,0.06)"],
|
||||||
|
div[style*="background: rgba(255,255,255,0.03)"][style*="border: 1px solid rgba(16,185,129,0.3)"] {
|
||||||
|
background: #F1F5F9 !important;
|
||||||
|
border-color: #E2E8F0 !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 11. Tables inside components */
|
||||||
|
table th {
|
||||||
|
color: #475569 !important;
|
||||||
|
background: #F1F5F9 !important;
|
||||||
|
border-bottom: 1px solid #CBD5E1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
table tr {
|
||||||
|
border-bottom: 1px solid #E2E8F0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
table tr:hover {
|
||||||
|
background: #F8FAFC !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 12. Crew Scheduling Page Specific Items */
|
||||||
|
/* Roster timeline assignments list containers */
|
||||||
|
div[style*="background: rgba(6,182,212,0.04)"][style*="border: 1px solid rgba(6,182,212,0.08)"],
|
||||||
|
div[style*="background:rgba(6,182,212,0.04)"][style*="border:1px solid rgba(6,182,212,0.08)"] {
|
||||||
|
background: rgba(6, 182, 212, 0.06) !important;
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[style*="background: rgba(59,130,246,0.04)"][style*="border: 1px solid rgba(59,130,246,0.08)"],
|
||||||
|
div[style*="background:rgba(59,130,246,0.04)"][style*="border:1px solid rgba(59,130,246,0.08)"] {
|
||||||
|
background: rgba(59, 130, 246, 0.06) !important;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[style*="background: rgba(16,185,129,0.04)"][style*="border: 1px solid rgba(16,185,129,0.08)"],
|
||||||
|
div[style*="background:rgba(16,185,129,0.04)"][style*="border:1px solid rgba(16,185,129,0.08)"] {
|
||||||
|
background: rgba(16, 185, 129, 0.06) !important;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 13. Empty / Placeholder States */
|
||||||
|
div[style*="border: 1px dashed rgba(255,255,255,0.08)"],
|
||||||
|
div[style*="border:1px dashed rgba(255,255,255,0.08)"],
|
||||||
|
div[style*="border: 1px dashed rgba(255,255,255,0.1)"],
|
||||||
|
div[style*="border:1px dashed rgba(255,255,255,0.1)"] {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border: 1px dashed #CBD5E1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 14. Broad Wildcard Overrides (Ensuring 100% full light theme coverage) */
|
||||||
|
.fleet-dashboard-content div[style*="rgba(255, 255, 255, 0.0"],
|
||||||
|
.fleet-dashboard-content div[style*="rgba(255,255,255,0.0"],
|
||||||
|
.fleet-dashboard-content div[style*="rgba(255, 255, 255, 0.1"],
|
||||||
|
.fleet-dashboard-content div[style*="rgba(255,255,255,0.1"] {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border-color: rgba(15, 23, 42, 0.08) !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-dashboard-content div[style*="color: #fff"],
|
||||||
|
.fleet-dashboard-content div[style*="color:#fff"],
|
||||||
|
.fleet-dashboard-content div[style*="color: #FFFFFF"],
|
||||||
|
.fleet-dashboard-content div[style*="color:#FFFFFF"],
|
||||||
|
.fleet-dashboard-content div[style*="color: rgb(255, 255, 255)"],
|
||||||
|
.fleet-dashboard-content p[style*="color: #fff"],
|
||||||
|
.fleet-dashboard-content p[style*="color:#fff"],
|
||||||
|
.fleet-dashboard-content span[style*="color: #fff"],
|
||||||
|
.fleet-dashboard-content span[style*="color:#fff"] {
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-dashboard-content div[style*="color: rgb(148, 163, 184)"],
|
||||||
|
.fleet-dashboard-content div[style*="color: #94A3B8"],
|
||||||
|
.fleet-dashboard-content div[style*="color:#94A3B8"] {
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-dashboard-content input,
|
||||||
|
.fleet-dashboard-content select,
|
||||||
|
.fleet-dashboard-content textarea {
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border: 1px solid #CBD5E1 !important;
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 15. Global Uniform Font Family */
|
||||||
|
.fleet-dashboard-container,
|
||||||
|
.fleet-dashboard-container *,
|
||||||
|
.sidebar,
|
||||||
|
.sidebar * {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 16. Custom Stations Search Input override (High-specificity to beat broad content overrides) */
|
||||||
|
.fleet-dashboard-content .stations-search-input,
|
||||||
|
.fleet-dashboard-content input.stations-search-input {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
color: #0F172A !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-dashboard-content .stations-search-input:focus,
|
||||||
|
.fleet-dashboard-content input.stations-search-input:focus {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
995
src/pages/fleet/FleetOrganization.tsx
Normal file
995
src/pages/fleet/FleetOrganization.tsx
Normal file
@@ -0,0 +1,995 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import {
|
||||||
|
Building2, MapPin, Phone, Plus, Search, MoreVertical,
|
||||||
|
Edit, Users, Truck, ShieldCheck, Globe, X, Loader2,
|
||||||
|
CheckCircle, AlertCircle, Navigation
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Fix default marker icons for Leaflet bundled via Vite
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Station {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
gps_lat?: number;
|
||||||
|
gps_lon?: number;
|
||||||
|
incharge_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
vehiclesAssigned?: number;
|
||||||
|
staffAssigned?: number;
|
||||||
|
status?: 'ACTIVE' | 'INACTIVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StationForm {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
incharge_name: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid rgba(15, 23, 42, 0.08)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px',
|
||||||
|
boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: '#475569',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '1px',
|
||||||
|
marginBottom: '6px',
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #CBD5E1',
|
||||||
|
padding: '11px 14px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: StationForm = {
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
incharge_name: '',
|
||||||
|
phone: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FleetOrganization: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'stations' | 'profile'>('stations');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
|
const [stations, setStations] = useState<Station[]>([]);
|
||||||
|
const [selectedStationForManifest, setSelectedStationForManifest] = useState<Station | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingStationId, setEditingStationId] = useState<string | null>(null);
|
||||||
|
const [loadingDetailsId, setLoadingDetailsId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<StationForm>(EMPTY_FORM);
|
||||||
|
const [gpsCoords, setGpsCoords] = useState<{ lat: number; lon: number } | null>(null);
|
||||||
|
const [gpsLoading, setGpsLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState('');
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
const markerRef = useRef<L.Marker | null>(null);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
|
// Get organisationId from stored user
|
||||||
|
const user = (() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('teleems_user') || '{}'); } catch { return {}; }
|
||||||
|
})();
|
||||||
|
const organisationId: string = user?.metadata?.organisation?.id || user?.organisationId || '';
|
||||||
|
|
||||||
|
const fetchStations = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError('');
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('teleems_token') || '';
|
||||||
|
if (!authToken) {
|
||||||
|
setFetchError('Session expired — no auth token found. Please log out and log in again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Stations] HTTP status:', res.status);
|
||||||
|
const json = await res.json();
|
||||||
|
console.log('[Stations] Response:', json);
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
setFetchError(`Session expired (${res.status}). Please log out and log in again to refresh your token.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setFetchError(`API error ${res.status}: ${json?.message || 'Unknown error'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
else if (Array.isArray(json)) list = json;
|
||||||
|
|
||||||
|
// 2. Fetch Vehicles for each station in parallel using the specific URL: /v1/fleet/vehicles?station_id=...
|
||||||
|
const mergedList = await Promise.all(
|
||||||
|
list.map(async (s) => {
|
||||||
|
let stationVehicles: any[] = [];
|
||||||
|
try {
|
||||||
|
const vehiclesUrl = `https://teleems-api-gateway.onrender.com/v1/fleet/vehicles?station_id=${s.id}`;
|
||||||
|
const vehiclesRes = await fetch(vehiclesUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${authToken}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (vehiclesRes.ok) {
|
||||||
|
const vJson = await vehiclesRes.json();
|
||||||
|
stationVehicles = vJson?.data?.data || vJson?.data || (Array.isArray(vJson) ? vJson : []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch vehicles for station ${s.id}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let staffSet = new Set<string>();
|
||||||
|
stationVehicles.forEach((v: any) => {
|
||||||
|
if (v.activeRoster) {
|
||||||
|
const r = v.activeRoster;
|
||||||
|
if (r.driver?.user?.name) staffSet.add(r.driver.user.name);
|
||||||
|
if (r.staff?.user?.name) staffSet.add(r.staff.user.name);
|
||||||
|
}
|
||||||
|
if (v.activeShift) {
|
||||||
|
const sh = v.activeShift;
|
||||||
|
if (sh.driver?.user?.name) staffSet.add(sh.driver.user.name);
|
||||||
|
if (sh.staff?.user?.name) staffSet.add(sh.staff.user.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
vehiclesAssigned: stationVehicles.length,
|
||||||
|
staffAssigned: staffSet.size
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setStations(mergedList);
|
||||||
|
} catch (e) {
|
||||||
|
setFetchError('Network error — unable to reach the server. Check your connection.');
|
||||||
|
console.error('Failed to fetch stations:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [organisationId]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchStations(); }, [fetchStations]);
|
||||||
|
|
||||||
|
// Refetch when user navigates back to the tab
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisible = () => { if (document.visibilityState === 'visible') fetchStations(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
}, [fetchStations]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize Leaflet map when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showModal) {
|
||||||
|
// Destroy map on close
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
markerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Wait for DOM
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!mapContainerRef.current || mapInstanceRef.current) return;
|
||||||
|
const initialLat = gpsCoords ? gpsCoords.lat : 20.5937;
|
||||||
|
const initialLon = gpsCoords ? gpsCoords.lon : 78.9629;
|
||||||
|
const initialZoom = gpsCoords ? 14 : 5;
|
||||||
|
|
||||||
|
const map = L.map(mapContainerRef.current, { zoomControl: true }).setView([initialLat, initialLon], initialZoom);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
if (gpsCoords) {
|
||||||
|
markerRef.current = L.marker([initialLat, initialLon]).addTo(map)
|
||||||
|
.bindPopup(`📍 ${initialLat.toFixed(6)}, ${initialLon.toFixed(6)}`).openPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
|
const { lat, lng } = e.latlng;
|
||||||
|
setGpsCoords({ lat, lon: lng });
|
||||||
|
if (markerRef.current) {
|
||||||
|
markerRef.current.setLatLng([lat, lng]);
|
||||||
|
} else {
|
||||||
|
markerRef.current = L.marker([lat, lng]).addTo(map)
|
||||||
|
.bindPopup(`📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}`).openPopup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [showModal, gpsCoords]);
|
||||||
|
|
||||||
|
const flyToCurrentLocation = () => {
|
||||||
|
setGpsLoading(true);
|
||||||
|
if (!navigator.geolocation) { setGpsLoading(false); return; }
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const { latitude: lat, longitude: lon } = pos.coords;
|
||||||
|
setGpsCoords({ lat, lon });
|
||||||
|
setGpsLoading(false);
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.flyTo([lat, lon], 15);
|
||||||
|
if (markerRef.current) {
|
||||||
|
markerRef.current.setLatLng([lat, lon]);
|
||||||
|
} else {
|
||||||
|
markerRef.current = L.marker([lat, lon]).addTo(mapInstanceRef.current!)
|
||||||
|
.bindPopup(`📍 ${lat.toFixed(6)}, ${lon.toFixed(6)}`).openPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => setGpsLoading(false),
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingStationId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setGpsCoords(null);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = async (station: Station) => {
|
||||||
|
setLoadingDetailsId(station.id);
|
||||||
|
try {
|
||||||
|
const res = await fleetApi.getStationDetails(station.id, token);
|
||||||
|
console.log('[Stations] Fetched single station details:', res);
|
||||||
|
|
||||||
|
const details = res?.data || res?.station || res || station;
|
||||||
|
|
||||||
|
setEditingStationId(station.id);
|
||||||
|
setForm({
|
||||||
|
name: details.name || station.name,
|
||||||
|
address: details.address || station.address,
|
||||||
|
incharge_name: details.incharge_name || station.incharge_name || '',
|
||||||
|
phone: details.phone || station.phone || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lat = details.gps_lat !== undefined ? details.gps_lat : station.gps_lat;
|
||||||
|
const lon = details.gps_lon !== undefined ? details.gps_lon : station.gps_lon;
|
||||||
|
|
||||||
|
if (lat && lon) {
|
||||||
|
setGpsCoords({ lat: Number(lat), lon: Number(lon) });
|
||||||
|
} else {
|
||||||
|
setGpsCoords(null);
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch station details, falling back to card data:', err);
|
||||||
|
// Fallback to local card data so the UI doesn't break
|
||||||
|
setEditingStationId(station.id);
|
||||||
|
setForm({
|
||||||
|
name: station.name,
|
||||||
|
address: station.address,
|
||||||
|
incharge_name: station.incharge_name || '',
|
||||||
|
phone: station.phone || '',
|
||||||
|
});
|
||||||
|
if (station.gps_lat && station.gps_lon) {
|
||||||
|
setGpsCoords({ lat: Number(station.gps_lat), lon: Number(station.gps_lon) });
|
||||||
|
} else {
|
||||||
|
setGpsCoords(null);
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
} finally {
|
||||||
|
setLoadingDetailsId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToast = (type: 'success' | 'error', message: string) => {
|
||||||
|
setToast({ type, message });
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
address: form.address,
|
||||||
|
gps_lat: gpsCoords?.lat ?? 0,
|
||||||
|
gps_lon: gpsCoords?.lon ?? 0,
|
||||||
|
incharge_name: form.incharge_name,
|
||||||
|
phone: form.phone,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (editingStationId) {
|
||||||
|
res = await fleetApi.updateStation(editingStationId, payload, token);
|
||||||
|
} else {
|
||||||
|
res = await fleetApi.createStation(payload, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res && (res.status === 200 || res.status === 201 || res.id || res.success || res.data)) {
|
||||||
|
showToast('success', editingStationId ? `Station "${form.name}" updated successfully!` : `Station "${form.name}" created successfully!`);
|
||||||
|
setShowModal(false);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setGpsCoords(null);
|
||||||
|
setEditingStationId(null);
|
||||||
|
fetchStations();
|
||||||
|
} else {
|
||||||
|
showToast('error', res?.message || `Failed to ${editingStationId ? 'update' : 'create'} station. Please try again.`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err?.message || 'Network error. Please check your connection.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredStations = stations.filter(s =>
|
||||||
|
s.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
s.incharge_name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ color: '#F8FAFC', position: 'relative' }}>
|
||||||
|
{/* Toast Notification */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20, x: '-50%' }}
|
||||||
|
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||||
|
exit={{ opacity: 0, y: -20, x: '-50%' }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', top: '24px', left: '50%', zIndex: 9999,
|
||||||
|
padding: '14px 24px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '10px',
|
||||||
|
background: toast.type === 'success' ? 'rgba(16, 185, 129, 0.15)' : 'rgba(239, 68, 68, 0.15)',
|
||||||
|
border: `1px solid ${toast.type === 'success' ? 'rgba(16,185,129,0.4)' : 'rgba(239,68,68,0.4)'}`,
|
||||||
|
color: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||||
|
fontWeight: 600, fontSize: '0.875rem',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
|
||||||
|
{toast.message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Add Station Modal */}
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModal && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(15, 23, 42, 0.3)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.92, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.92, opacity: 0 }}
|
||||||
|
style={{ background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: '20px', padding: '24px 32px', width: '880px', maxWidth: '95vw', maxHeight: '96vh', overflowY: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 50px rgba(15, 23, 42, 0.12)' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.3rem', fontWeight: 900, color: '#0F172A', margin: 0 }}>{editingStationId ? 'Edit Station' : 'Add New Station'}</h2>
|
||||||
|
<p style={{ fontSize: '0.78rem', color: '#475569', margin: '2px 0 0' }}>{editingStationId ? 'Update dispatch station details' : 'Create a new dispatch station'}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setShowModal(false)} style={{ background: '#F1F5F9', border: '1px solid #E2E8F0', borderRadius: '8px', padding: '8px', color: '#475569', cursor: 'pointer', display: 'flex' }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: '28px', alignItems: 'start' }}>
|
||||||
|
|
||||||
|
{/* Left Column: Form Fields */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Station Name *</label>
|
||||||
|
<input style={inputStyle} placeholder="e.g. Alpha Dispatch Hub" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Address *</label>
|
||||||
|
<textarea style={{ ...inputStyle, resize: 'none', height: '64px' }} placeholder="Full address of the station" required value={form.address} onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Incharge Name *</label>
|
||||||
|
<input style={inputStyle} placeholder="Station incharge full name" required value={form.incharge_name} onChange={e => setForm(f => ({ ...f, incharge_name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Contact Phone *</label>
|
||||||
|
<input style={inputStyle} placeholder="+91-9876543210" required value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Interactive Map Picker */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>GPS Location — Click on map to set pin</label>
|
||||||
|
<div style={{ position: 'relative', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(15, 23, 42, 0.08)' }}>
|
||||||
|
<div ref={mapContainerRef} style={{ height: '210px', width: '100%', background: '#F1F5F9' }} />
|
||||||
|
{/* Overlay UI */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={flyToCurrentLocation}
|
||||||
|
disabled={gpsLoading}
|
||||||
|
style={{ position: 'absolute', top: '10px', right: '10px', zIndex: 1000, padding: '6px 12px', background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(6,182,212,0.4)', borderRadius: '8px', color: '#06B6D4', cursor: gpsLoading ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.7rem', fontWeight: 600, backdropFilter: 'blur(8px)' }}
|
||||||
|
>
|
||||||
|
{gpsLoading ? <Loader2 size={12} className="spin" /> : <Navigation size={12} />}
|
||||||
|
{gpsLoading ? 'Locating...' : 'My Location'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<div style={{ padding: '8px 12px', borderRadius: '8px', background: gpsCoords ? 'rgba(16,185,129,0.08)' : '#F1F5F9', border: `1px solid ${gpsCoords ? 'rgba(16,185,129,0.2)' : '#E2E8F0'}`, color: gpsCoords ? '#10B981' : '#475569', fontSize: '0.75rem', fontFamily: 'monospace' }}>
|
||||||
|
{gpsCoords ? `Lat: ${gpsCoords.lat.toFixed(6)} | Lon: ${gpsCoords.lon.toFixed(6)}` : '📍 Click on the map to place a pin'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons placed cleanly inside the right column at the bottom */}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}>
|
||||||
|
<button type="button" onClick={() => setShowModal(false)}
|
||||||
|
style={{ flex: 1, padding: '11px', background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: '12px', color: '#475569', cursor: 'pointer', fontWeight: 600, fontSize: '0.85rem' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={submitting}
|
||||||
|
style={{ flex: 1.5, padding: '11px', background: submitting ? 'rgba(6,182,212,0.3)' : 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '12px', color: '#fff', cursor: submitting ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '0.85rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', boxShadow: '0 4px 16px rgba(6,182,212,0.3)' }}>
|
||||||
|
{submitting ? (
|
||||||
|
<><Loader2 size={16} className="spin" /> {editingStationId ? 'Updating...' : 'Creating...'}</>
|
||||||
|
) : (
|
||||||
|
<><Edit size={16} /> {editingStationId ? 'UPDATE' : 'CREATE'}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Toggle */}
|
||||||
|
<div style={{ display: 'flex', gap: '4px', marginBottom: '28px', background: 'rgba(255,255,255,0.03)', padding: '4px', borderRadius: '12px', width: 'fit-content', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
{(['stations', 'profile'] as const).map(tab => (
|
||||||
|
<button key={tab} onClick={() => setActiveTab(tab)} style={{ padding: '10px 24px', borderRadius: '10px', border: 'none', background: activeTab === tab ? 'linear-gradient(135deg, #06B6D4, #3B82F6)' : 'transparent', color: activeTab === tab ? '#fff' : '#64748B', fontWeight: activeTab === tab ? 700 : 500, fontSize: '0.875rem', cursor: 'pointer', transition: 'all 0.3s ease', display: 'flex', alignItems: 'center', gap: '8px', boxShadow: activeTab === tab ? '0 4px 12px rgba(6,182,212,0.3)' : 'none' }}>
|
||||||
|
{tab === 'stations' ? <><MapPin size={16} /> Station Management</> : <><Building2 size={16} /> Organisation Profile</>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'profile' ? (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px' }}>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 800, marginBottom: '24px', color: '#fff' }}>Fleet Operator Profile</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
||||||
|
<div><label style={labelStyle}>Full Name</label><input style={inputStyle} defaultValue={user?.name || ''} placeholder="Full name" /></div>
|
||||||
|
<div><label style={labelStyle}>Username</label><input style={{ ...inputStyle, fontFamily: 'monospace', color: '#06B6D4' }} defaultValue={user?.username || ''} readOnly /></div>
|
||||||
|
<div><label style={labelStyle}>Email Address</label><input style={inputStyle} type="email" defaultValue={user?.email || ''} placeholder="Email" /></div>
|
||||||
|
<div><label style={labelStyle}>Phone Number</label><input style={inputStyle} defaultValue={user?.phone || ''} placeholder="+91-XXXXXXXXXX" /></div>
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}><label style={labelStyle}>Organisation ID</label><input style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.78rem', color: '#94A3B8' }} defaultValue={user?.organisationId || organisationId || ''} readOnly /></div>
|
||||||
|
{user?.department && <div><label style={labelStyle}>Department</label><input style={inputStyle} defaultValue={user.department} /></div>}
|
||||||
|
{user?.designation && <div><label style={labelStyle}>Designation</label><input style={inputStyle} defaultValue={user.designation} /></div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button style={{ padding: '12px 28px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '10px', color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem' }}>SAVE CHANGES</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div style={{ ...cardStyle, textAlign: 'center' }}>
|
||||||
|
<div style={{ width: '72px', height: '72px', borderRadius: '50%', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px', fontSize: '1.5rem', fontWeight: 900, color: '#fff', boxShadow: '0 0 24px rgba(6,182,212,0.3)' }}>
|
||||||
|
{(user?.name || 'FO').split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)}
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 800 }}>{user?.name || 'Fleet Operator'}</h3>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#64748B', marginTop: '4px' }}>{user?.email || ''}</p>
|
||||||
|
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<div style={{ padding: '8px', background: user?.status === 'ACTIVE' ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.1)', border: `1px solid ${user?.status === 'ACTIVE' ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'}`, borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', color: user?.status === 'ACTIVE' ? '#10B981' : '#EF4444', fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
<ShieldCheck size={14} /> {user?.status || 'ACTIVE'}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px', background: 'rgba(6,182,212,0.06)', border: '1px solid rgba(6,182,212,0.12)', borderRadius: '8px', color: '#475569', fontSize: '0.68rem', fontFamily: 'monospace', wordBreak: 'break-all', textAlign: 'left' }}>
|
||||||
|
ORG: {user?.organisationId || organisationId || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h4 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '16px', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Quick Stats</h4>
|
||||||
|
{[{ icon: MapPin, label: 'Active Stations', value: stations.length || 0 }, { icon: Truck, label: 'Total Vehicles', value: 25 }, { icon: Users, label: 'Total Staff', value: 71 }].map(({ icon: Icon, label, value }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#94A3B8', fontSize: '0.875rem' }}><Icon size={16} color="#06B6D4" /> {label}</div>
|
||||||
|
<span style={{ fontWeight: 900, fontSize: '1.25rem', color: '#fff' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isSearchFocused ? '1px solid #06B6D4' : '1px solid #CBD5E1',
|
||||||
|
boxShadow: isSearchFocused ? '0 0 0 3px rgba(6, 182, 212, 0.15)' : '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
width: '320px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<Search size={16} color={isSearchFocused ? "#06B6D4" : "#64748B"} style={{ transition: 'color 0.2s' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search stations, incharge..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
className="stations-search-input"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
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>
|
||||||
|
<button onClick={openCreateModal}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '11px 22px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '12px', color: '#fff', fontWeight: 700, cursor: 'pointer', boxShadow: '0 4px 12px rgba(6,182,212,0.3)', fontSize: '0.875rem' }}>
|
||||||
|
<Plus size={18} /> ADD NEW STATION
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '200px', gap: '12px', color: '#64748B' }}>
|
||||||
|
<Loader2 size={24} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
<span>Loading stations...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{!loading && fetchError && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 24px', background: 'rgba(239,68,68,0.05)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: '16px', marginBottom: '20px' }}>
|
||||||
|
<AlertCircle size={36} color="#EF4444" style={{ marginBottom: '12px', opacity: 0.8 }} />
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 700, color: '#EF4444', marginBottom: '8px' }}>Failed to Load Stations</h3>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: '#94A3B8', marginBottom: '16px' }}>{fetchError}</p>
|
||||||
|
<button onClick={fetchStations} style={{ padding: '10px 24px', background: 'linear-gradient(135deg, #06B6D4, #3B82F6)', border: 'none', borderRadius: '10px', color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||||
|
↺ Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && !fetchError && filteredStations.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#64748B' }}>
|
||||||
|
<MapPin size={48} style={{ opacity: 0.2, marginBottom: '16px' }} />
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#94A3B8', marginBottom: '8px' }}>No Stations Found</h3>
|
||||||
|
<p style={{ fontSize: '0.875rem' }}>Click "Add New Station" to create your first dispatch station.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Station Cards */}
|
||||||
|
{!loading && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '20px' }}>
|
||||||
|
{filteredStations.map(station => (
|
||||||
|
<div key={station.id} style={{ ...cardStyle, position: 'relative', overflow: 'hidden', transition: 'transform 0.2s, box-shadow 0.2s', cursor: 'pointer' }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)'; (e.currentTarget as HTMLElement).style.boxShadow = '0 12px 32px rgba(15, 23, 42, 0.06)'; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '4px', height: '100%', background: station.status === 'INACTIVE' ? '#EF4444' : '#10B981', borderRadius: '4px 0 0 4px' }} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 800, color: '#0F172A' }}>{station.name}</h3>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#475569', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Globe size={11} /> {station.id?.substring(0, 8)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button style={{ background: 'transparent', border: 'none', color: '#475569', cursor: 'pointer', padding: '4px' }}><MoreVertical size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
|
<MapPin size={15} color="#06B6D4" style={{ marginTop: '2px', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#334155' }}>{station.address}</div>
|
||||||
|
{(station.gps_lat || station.gps_lon) && (
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', marginTop: '2px' }}>
|
||||||
|
GPS: {station.gps_lat}, {station.gps_lon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{station.incharge_name && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<Users size={15} color="#06B6D4" />
|
||||||
|
<span style={{ fontSize: '0.85rem', color: '#475569' }}>Incharge: <span style={{ fontWeight: 700, color: '#0F172A' }}>{station.incharge_name}</span></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{station.phone && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<Phone size={15} color="#06B6D4" />
|
||||||
|
<span style={{ fontSize: '0.85rem', color: '#475569' }}>{station.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px', background: '#E2E8F0', borderRadius: '10px', overflow: 'hidden', marginBottom: '16px', border: '1px solid #E2E8F0' }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '14px', background: '#F8FAFC' }}>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#0F172A' }}>{station.vehiclesAssigned ?? '—'}</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.5px', marginTop: '4px' }}>Vehicles</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: '14px', background: '#F8FAFC' }}>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#0F172A' }}>{station.staffAssigned ?? '—'}</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.5px', marginTop: '4px' }}>Staff</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); openEditModal(station); }}
|
||||||
|
disabled={loadingDetailsId !== null}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 70px', padding: '9px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #CBD5E1',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: loadingDetailsId === station.id ? '#06B6D4' : '#475569',
|
||||||
|
fontSize: '0.75rem', fontWeight: 600,
|
||||||
|
cursor: loadingDetailsId !== null ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingDetailsId === station.id ? (
|
||||||
|
<Loader2 size={13} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Edit size={13} />
|
||||||
|
)}
|
||||||
|
{loadingDetailsId === station.id ? 'FETCHING...' : 'EDIT'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setSelectedStationForManifest(station); }}
|
||||||
|
style={{ flex: '1.2 1 100px', padding: '9px', background: 'rgba(16, 185, 129, 0.08)', border: '1px solid rgba(16, 185, 129, 0.2)', borderRadius: '8px', color: '#10B981', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<Users size={13} /> MANIFEST
|
||||||
|
</button>
|
||||||
|
{/* <button style={{ flex: '1.5 1 110px', padding: '9px', background: 'rgba(6,182,212,0.08)', border: '1px solid rgba(6,182,212,0.2)', borderRadius: '8px', color: '#06B6D4', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
|
||||||
|
<Truck size={13} /> ASSIGN ASSETS
|
||||||
|
</button> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Station Manifest Modal */}
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedStationForManifest && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(15, 23, 42, 0.3)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={() => setSelectedStationForManifest(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.92, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.92, opacity: 0 }}
|
||||||
|
style={{ background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: '20px', padding: '24px 32px', width: '750px', maxWidth: '95vw', maxHeight: '90vh', overflowY: 'auto', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 50px rgba(15, 23, 42, 0.12)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', borderBottom: '1px solid #F1F5F9', paddingBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 900, color: '#0F172A', margin: 0 }}>
|
||||||
|
{selectedStationForManifest.name.toUpperCase()} MANIFEST
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.78rem', color: '#64748B', margin: '4px 0 0' }}>
|
||||||
|
📍 {selectedStationForManifest.address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setSelectedStationForManifest(null)} style={{ background: '#F1F5F9', border: '1px solid #E2E8F0', borderRadius: '8px', padding: '8px', color: '#475569', cursor: 'pointer', display: 'flex' }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StationManifestContent stationId={selectedStationForManifest.id} />
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .spin { animation: spin 1s linear infinite; }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ManifestVehicle {
|
||||||
|
id: string;
|
||||||
|
registration_number: string;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
vehicle_type?: string;
|
||||||
|
status: string;
|
||||||
|
activeRoster?: {
|
||||||
|
driver?: { user?: { name: string; phone?: string } };
|
||||||
|
staff?: { user?: { name: string; phone?: string } };
|
||||||
|
};
|
||||||
|
activeShift?: {
|
||||||
|
driver?: { user?: { name: string; phone?: string } };
|
||||||
|
staff?: { user?: { name: string; phone?: string } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const StationManifestContent: React.FC<{ stationId: string }> = ({ stationId }) => {
|
||||||
|
const [vehicles, setVehicles] = useState<ManifestVehicle[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const authToken = localStorage.getItem('teleems_token') || '';
|
||||||
|
if (!authToken) throw new Error('Session expired — please log in again.');
|
||||||
|
|
||||||
|
const url = `https://teleems-api-gateway.onrender.com/v1/fleet/vehicles?station_id=${stationId}`;
|
||||||
|
console.log('[Manifest] Calling:', url);
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP error ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (active) {
|
||||||
|
let list = json?.data?.data || json?.data || (Array.isArray(json) ? json : []);
|
||||||
|
setVehicles(list);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (active) {
|
||||||
|
setError(err.message || 'Failed to fetch station assets');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [stationId]);
|
||||||
|
|
||||||
|
const crewList = React.useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; role: string; phone?: string }>();
|
||||||
|
vehicles.forEach(v => {
|
||||||
|
const roster = v.activeRoster;
|
||||||
|
if (roster) {
|
||||||
|
if (roster.driver?.user?.name) {
|
||||||
|
map.set(roster.driver.user.name, {
|
||||||
|
name: roster.driver.user.name,
|
||||||
|
role: 'Pilot (Driver)',
|
||||||
|
phone: roster.driver.user.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roster.staff?.user?.name) {
|
||||||
|
map.set(roster.staff.user.name, {
|
||||||
|
name: roster.staff.user.name,
|
||||||
|
role: 'EMT (Medic)',
|
||||||
|
phone: roster.staff.user.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const shift = v.activeShift;
|
||||||
|
if (shift) {
|
||||||
|
if (shift.driver?.user?.name) {
|
||||||
|
map.set(shift.driver.user.name, {
|
||||||
|
name: shift.driver.user.name,
|
||||||
|
role: 'Pilot (Driver)',
|
||||||
|
phone: shift.driver.user.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (shift.staff?.user?.name) {
|
||||||
|
map.set(shift.staff.user.name, {
|
||||||
|
name: shift.staff.user.name,
|
||||||
|
role: 'EMT (Medic)',
|
||||||
|
phone: shift.staff.user.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(map.values());
|
||||||
|
}, [vehicles]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '60px 0', gap: '12px' }}>
|
||||||
|
<Loader2 className="spin" size={28} color="#06B6D4" style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
<span style={{ color: '#64748B', fontSize: '0.85rem', fontWeight: 600 }}>FETCHING STATION ASSETS...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', color: '#EF4444', background: 'rgba(239, 68, 68, 0.05)', borderRadius: '12px', border: '1px solid rgba(239, 68, 68, 0.15)' }}>
|
||||||
|
<AlertCircle size={28} style={{ marginBottom: '8px', display: 'inline-block' }} />
|
||||||
|
<div style={{ fontSize: '0.875rem', fontWeight: 700 }}>Error Loading Manifest</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', opacity: 0.8, marginTop: '4px' }}>{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '24px', minHeight: '300px' }}>
|
||||||
|
{/* Vehicles Column */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<h4 style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.9rem', fontWeight: 800, color: '#0F172A', margin: 0, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||||
|
<Truck size={18} color="#06B6D4" /> Deployed Fleet ({vehicles.length})
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{vehicles.length === 0 ? (
|
||||||
|
<div style={{ padding: '40px 16px', textAlign: 'center', background: '#F8FAFC', borderRadius: '12px', border: '1px dashed #E2E8F0', color: '#64748B', fontSize: '0.82rem', fontStyle: 'italic' }}>
|
||||||
|
No vehicles currently docked at this station.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', maxHeight: '50vh', overflowY: 'auto', paddingRight: '4px' }}>
|
||||||
|
{vehicles.map(v => {
|
||||||
|
const driverName = v.activeRoster?.driver?.user?.name || v.activeShift?.driver?.user?.name;
|
||||||
|
const emtName = v.activeRoster?.staff?.user?.name || v.activeShift?.staff?.user?.name;
|
||||||
|
return (
|
||||||
|
<div key={v.id} style={{ padding: '14px', background: '#F8FAFC', borderRadius: '12px', border: '1px solid #E2E8F0', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.9rem', fontWeight: 800, color: '#0F172A' }}>{v.registration_number}</div>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: '#475569', marginTop: '2px' }}>{v.brand} {v.model} • {v.vehicle_type}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
background: v.status === 'AVAILABLE' ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||||
|
color: v.status === 'AVAILABLE' ? '#10B981' : '#EF4444'
|
||||||
|
}}>
|
||||||
|
{v.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(driverName || emtName) && (
|
||||||
|
<div style={{ borderTop: '1px solid #E2E8F0', paddingTop: '8px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
{driverName && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#475569', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<span style={{ color: '#94A3B8', fontWeight: 600 }}>Pilot:</span>
|
||||||
|
<strong style={{ color: '#0F172A' }}>{driverName}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{emtName && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#475569', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<span style={{ color: '#94A3B8', fontWeight: 600 }}>EMT:</span>
|
||||||
|
<strong style={{ color: '#0F172A' }}>{emtName}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Staff Column */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<h4 style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.9rem', fontWeight: 800, color: '#0F172A', margin: 0, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||||
|
<Users size={18} color="#06B6D4" /> Active Crew ({crewList.length})
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{crewList.length === 0 ? (
|
||||||
|
<div style={{ padding: '40px 16px', textAlign: 'center', background: '#F8FAFC', borderRadius: '12px', border: '1px dashed #E2E8F0', color: '#64748B', fontSize: '0.82rem', fontStyle: 'italic' }}>
|
||||||
|
No crew members currently on duty.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', maxHeight: '50vh', overflowY: 'auto' }}>
|
||||||
|
{crewList.map((c, i) => (
|
||||||
|
<div key={i} style={{ padding: '12px 14px', background: '#F8FAFC', borderRadius: '12px', border: '1px solid #E2E8F0', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ width: '34px', height: '34px', borderRadius: '50%', background: 'rgba(6, 182, 212, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#06B6D4' }}>
|
||||||
|
<Users size={16} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#0F172A' }}>{c.name}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#475569', marginTop: '2px' }}>{c.role} {c.phone ? `• ${c.phone}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
393
src/pages/fleet/FleetPendingRequests.tsx
Normal file
393
src/pages/fleet/FleetPendingRequests.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Package,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
Check,
|
||||||
|
Truck
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Card } from '../../components/Common';
|
||||||
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
|
||||||
|
export const FleetPendingRequests: React.FC = () => {
|
||||||
|
const [, setSearchParams] = useSearchParams();
|
||||||
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filtering & Pagination states
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('PENDING');
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const itemsPerPage = 8;
|
||||||
|
|
||||||
|
// Inline loading when clicking action buttons
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
if (!token) {
|
||||||
|
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 data = res?.data?.data || res?.data || [];
|
||||||
|
setRequests(Array.isArray(data) ? data : []);
|
||||||
|
setCurrentPage(1);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch restock requests:', err);
|
||||||
|
setError(err?.message || 'Failed to fetch restock requests.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRequests();
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
const handleUpdateStatus = async (requestId: string, nextStatus: 'APPROVED' | 'COMPLETED') => {
|
||||||
|
if (!requestId) return;
|
||||||
|
setProcessingId(requestId);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
await fleetApi.updateRestockRequestStatus(requestId, nextStatus, token);
|
||||||
|
alert(`Request has been successfully marked as ${nextStatus}!`);
|
||||||
|
// Reload current tab requests
|
||||||
|
fetchRequests();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to update request status:', err);
|
||||||
|
alert(err?.message || `Failed to mark request as ${nextStatus}. Please try again.`);
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination calculation
|
||||||
|
const totalPages = Math.ceil(filteredRequests.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredRequests.slice(indexOfFirstItem, indexOfLastItem);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in duration-500" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{/* Top action bar */}
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchParams({ tab: 'warehouse' })}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
color: '#94A3B8',
|
||||||
|
borderRadius: '12px',
|
||||||
|
width: '52px',
|
||||||
|
height: '42px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.1)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
|
||||||
|
title="Back to Warehouse Stock"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Search filter input */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isSearchFocused ? '1px solid #06B6D4' : '1px solid #CBD5E1',
|
||||||
|
boxShadow: isSearchFocused ? '0 0 0 3px rgba(6, 182, 212, 0.15)' : '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
width: '320px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<Search size={16} color={isSearchFocused ? "#06B6D4" : "#64748B"} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search request items or users..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
className="stations-search-input"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filters */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'PENDING', label: 'Pending Approval', count: statusFilter === 'PENDING' ? filteredRequests.length : null },
|
||||||
|
{ id: 'APPROVED', label: 'Approved (Awaiting Handover)', count: statusFilter === 'APPROVED' ? filteredRequests.length : null }
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(tab.id);
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: statusFilter === tab.id ? '#06B6D4' : '#FFFFFF',
|
||||||
|
color: statusFilter === tab.id ? '#FFFFFF' : '#475569',
|
||||||
|
border: statusFilter === tab.id ? '1px solid #06B6D4' : '1px solid #CBD5E1',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '10px 18px',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
boxShadow: statusFilter === tab.id ? '0 4px 12px rgba(6, 182, 212, 0.2)' : '0 1px 2px rgba(15, 23, 42, 0.05)',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== null && (
|
||||||
|
<span style={{
|
||||||
|
background: statusFilter === tab.id ? '#FFFFFF' : 'rgba(6, 182, 212, 0.1)',
|
||||||
|
color: statusFilter === tab.id ? '#06B6D4' : '#06B6D4',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 800
|
||||||
|
}}>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title={`${statusFilter.charAt(0) + statusFilter.slice(1).toLowerCase()} Restock Requests Ledger`}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '100px', gap: '16px', color: '#64748B' }}>
|
||||||
|
<Activity size={32} className="spin" style={{ color: '#06B6D4' }} />
|
||||||
|
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>FETCHING TRANSACTION RECORDS...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '80px 24px', textAlign: 'center', color: '#EF4444' }}>
|
||||||
|
<AlertTriangle size={48} style={{ marginBottom: '16px', opacity: 0.8 }} />
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, marginBottom: '8px' }}>Synchronization Failed</h3>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: '#94A3B8', maxWidth: '360px', margin: '0 auto' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
) : currentItems.length === 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '100px 24px', color: '#64748B' }}>
|
||||||
|
<Package size={48} style={{ opacity: 0.2, marginBottom: '16px', color: '#06B6D4' }} />
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 700, color: '#475569', marginBottom: '4px' }}>No Supply Requests</h3>
|
||||||
|
<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>
|
||||||
|
{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'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '2px', fontWeight: 500 }}>
|
||||||
|
Category: {req.item_master?.category || 'General'}
|
||||||
|
</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>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px 12px' }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.8rem', color: '#475569' }}>
|
||||||
|
{requestedUser}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px 12px' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||||
|
{new Date(req.createdAt || req.updatedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px 12px' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 900,
|
||||||
|
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'
|
||||||
|
}}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,205 +1,511 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { UserPlus, Search, ShieldCheck, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, AlertCircle, Phone, Mail, X, Eye, EyeOff } from 'lucide-react';
|
||||||
UserPlus,
|
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
Users,
|
|
||||||
Medal,
|
|
||||||
Clock,
|
|
||||||
ShieldCheck,
|
|
||||||
AlertTriangle,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
Calendar,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
MoreVertical
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card } from '../../components/Common';
|
|
||||||
|
|
||||||
interface Staff {
|
interface Staff {
|
||||||
id: string;
|
id: string; type?: string; status?: string; createdAt?: string; aadhaar_number?: string;
|
||||||
name: string;
|
professional_details?: { qualification?: string; certificate_expiry?: string; certificate_number?: string; certification_body?: string; license_expiry?: string; license_number?: string; license_category?: string; };
|
||||||
role: 'DRIVER' | 'EMT' | 'DOCTOR' | 'PARAMEDIC';
|
user?: { name?: string; phone?: string; email?: string | null; status?: string; isAvailable?: boolean; };
|
||||||
status: 'ON_DUTY' | 'OFF_DUTY' | 'ON_LEAVE';
|
|
||||||
specialization?: string;
|
|
||||||
phone: string;
|
|
||||||
email: string;
|
|
||||||
joinedDate: string;
|
|
||||||
tripsCompleted: number;
|
|
||||||
rating: number;
|
|
||||||
certExpiry: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_STAFF: Staff[] = [
|
const ROLE_COLORS: Record<string, { color: string; bg: string }> = {
|
||||||
{ id: 'S-101', name: 'Vikram Singh', role: 'DRIVER', status: 'ON_DUTY', phone: '+91 98765 43210', email: 'v.singh@teleems.com', joinedDate: '2023-01-15', tripsCompleted: 452, rating: 4.8, certExpiry: '2026-12-01' },
|
DRIVER: { color: '#06B6D4', bg: 'rgba(6,182,212,0.08)' },
|
||||||
{ id: 'S-102', name: 'Dr. Ananya Iyer', role: 'DOCTOR', specialization: 'Critical Care', status: 'ON_DUTY', phone: '+91 98765 43211', email: 'a.iyer@teleems.com', joinedDate: '2023-06-20', tripsCompleted: 128, rating: 4.9, certExpiry: '2026-05-15' },
|
EMT: { color: '#3B82F6', bg: 'rgba(59,130,246,0.08)' },
|
||||||
{ id: 'S-103', name: 'Rahul Verma', role: 'EMT', status: 'ON_LEAVE', phone: '+91 98765 43212', email: 'r.verma@teleems.com', joinedDate: '2024-02-10', tripsCompleted: 215, rating: 4.7, certExpiry: '2026-06-01' },
|
DOCTOR: { color: '#10B981', bg: 'rgba(16,185,129,0.08)' },
|
||||||
{ id: 'S-104', name: 'Suresh Kumar', role: 'DRIVER', status: 'OFF_DUTY', phone: '+91 98765 43213', email: 's.kumar@teleems.com', joinedDate: '2022-11-05', tripsCompleted: 890, rating: 4.6, certExpiry: '2026-08-20' },
|
};
|
||||||
];
|
const DEF = { color: '#475569', bg: 'rgba(71,85,105,0.06)' };
|
||||||
|
const STATUS_CFG: Record<string, { label: string; color: string }> = {
|
||||||
|
ON_DUTY: { label: 'On Duty', color: '#10B981' }, OFF_DUTY: { label: 'Off Duty', color: '#475569' },
|
||||||
|
ON_LEAVE: { label: 'On Leave', color: '#F59E0B' }, ACTIVE: { label: 'Active', color: '#10B981' }, INACTIVE: { label: 'Inactive', color: '#EF4444' },
|
||||||
|
};
|
||||||
|
const FILTERS = ['ALL', 'DRIVER', 'EMT', 'DOCTOR'] as const;
|
||||||
|
const th: React.CSSProperties = { padding: '14px 18px', fontSize: '0.68rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, textAlign: 'left', borderBottom: '1px solid #CBD5E1', background: '#F1F5F9', whiteSpace: 'nowrap' };
|
||||||
|
|
||||||
|
const norm = (s: Staff) => {
|
||||||
|
const pd = s.professional_details;
|
||||||
|
return {
|
||||||
|
id: s.id, name: s.user?.name || '—', role: (s.type || '').toUpperCase(),
|
||||||
|
status: (s.status || s.user?.status || 'ACTIVE').toUpperCase(),
|
||||||
|
phone: s.user?.phone || '—', email: s.user?.email || null,
|
||||||
|
joinedDate: s.createdAt ? s.createdAt.substring(0, 10) : '',
|
||||||
|
specialization: pd?.qualification || pd?.license_category || pd?.certification_body || '',
|
||||||
|
certExpiry: pd?.certificate_expiry || pd?.license_expiry || '',
|
||||||
|
isAvailable: s.user?.isAvailable ?? true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const FleetPersonnel: React.FC = () => {
|
export const FleetPersonnel: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'ALL' | 'DRIVER' | 'EMT' | 'DOCTOR'>('ALL');
|
const [staffList, setStaffList] = useState<Staff[]>([]);
|
||||||
const [selectedStaff, setSelectedStaff] = useState<Staff | null>(null);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState('');
|
||||||
|
const [filter, setFilter] = useState<typeof FILTERS[number]>('ALL');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(5);
|
||||||
|
const [selectedRaw, setSelectedRaw] = useState<Staff | null>(null);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitErr, setSubmitErr] = useState('');
|
||||||
|
const [submitOk, setSubmitOk] = useState('');
|
||||||
|
const [showPw, setShowPw] = useState(false);
|
||||||
|
const [staffType, setStaffType] = useState<'DRIVER'|'EMT'|'DOCTOR'>('DRIVER');
|
||||||
|
const [form, setForm] = useState({ 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,
|
||||||
|
});
|
||||||
|
const setF = (k: string, v: string | boolean) => setForm(p => ({ ...p, [k]: v }));
|
||||||
|
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 filteredStaff = MOCK_STAFF.filter(s => activeTab === 'ALL' || s.role === activeTab);
|
const fetchStaff = useCallback(async () => {
|
||||||
|
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; }
|
||||||
|
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); }
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const submitStaff = async () => {
|
||||||
|
if (!form.name || !form.phone) { setSubmitErr('Name and phone are required.'); return; }
|
||||||
|
setSubmitting(true); setSubmitErr(''); setSubmitOk('');
|
||||||
|
let professional_details: Record<string, string | boolean> = {};
|
||||||
|
if (staffType === 'DRIVER') professional_details = { license_number: form.license_number, license_category: form.license_category, license_expiry: form.license_expiry };
|
||||||
|
if (staffType === 'EMT') professional_details = { qualification: form.qualification, certification_body: form.certification_body, certificate_number: form.certificate_number, certificate_expiry: form.certificate_expiry };
|
||||||
|
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; }
|
||||||
|
setSubmitOk(`${staffType} registered successfully!`);
|
||||||
|
resetModal();
|
||||||
|
fetchStaff();
|
||||||
|
setTimeout(() => { setShowModal(false); setSubmitOk(''); }, 1500);
|
||||||
|
} catch { setSubmitErr('Network error.'); } finally { setSubmitting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchStaff(); }, [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())));
|
||||||
|
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) {
|
||||||
|
const s = norm(selectedRaw);
|
||||||
|
const pd = selectedRaw.professional_details;
|
||||||
|
const rc = ROLE_COLORS[s.role] || DEF;
|
||||||
|
const sc = STATUS_CFG[s.status] || { label: s.status, color: '#94A3B8' };
|
||||||
|
const ini = s.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
||||||
|
const cw = s.certExpiry ? new Date(s.certExpiry) < new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) : false;
|
||||||
|
const row = (label: string, value: string, mono = false) => (
|
||||||
|
<div style={{ padding: '14px 16px', background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: '12px' }}>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' }}>{label}</div>
|
||||||
|
<div style={{ fontSize: '0.9rem', color: '#0F172A', fontWeight: 600, fontFamily: mono ? 'monospace' : undefined, wordBreak: 'break-all' }}>{value || '—'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="fleet-personnel animate-in fade-in duration-500">
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} style={{ color: '#0F172A' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
<button onClick={() => setSelectedRaw(null)} style={{ display: 'flex', alignItems: 'center', gap: '8px', background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: '10px', padding: '9px 16px', color: '#475569', cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600, marginBottom: '24px' }}>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<ChevronLeft size={16} /> Back to Staff List
|
||||||
{['ALL', 'DRIVER', 'EMT', 'DOCTOR'].map(t => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => setActiveTab(t as any)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
|
||||||
background: activeTab === t ? 'var(--accent-cyan)' : 'rgba(255,255,255,0.05)',
|
|
||||||
color: activeTab === t ? '#000' : 'var(--text-secondary)',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t}S
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<UserPlus size={18} /> REGISTER PERSONNEL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '24px' }}>
|
{/* Hero */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ background: `linear-gradient(135deg,${rc.color}08,#FFFFFF)`, border: `1px solid ${rc.color}30`, borderRadius: '20px', padding: '32px', marginBottom: '20px', position: 'relative', overflow: 'hidden', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
<Card style={{ padding: '0', overflow: 'hidden' }}>
|
<div style={{ position: 'absolute', top: -40, right: -40, width: 180, height: 180, borderRadius: '50%', background: `${rc.color}10` }} />
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||||
<thead>
|
<div style={{ width: 80, height: 80, borderRadius: 22, background: rc.bg, border: `2px solid ${rc.color}60`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.6rem', fontWeight: 900, color: rc.color, boxShadow: `0 0 28px ${rc.color}30`, flexShrink: 0 }}>{ini}</div>
|
||||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.1)', textAlign: 'left', background: 'rgba(255,255,255,0.02)' }}>
|
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Personnel</th>
|
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Role / Specialization</th>
|
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Status</th>
|
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Trips</th>
|
|
||||||
<th style={{ padding: '16px', fontSize: '0.75rem', textTransform: 'uppercase', opacity: 0.5 }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredStaff.map(s => (
|
|
||||||
<tr
|
|
||||||
key={s.id}
|
|
||||||
onClick={() => setSelectedStaff(s)}
|
|
||||||
style={{
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: selectedStaff?.id === s.id ? 'rgba(59, 130, 246, 0.05)' : 'transparent'
|
|
||||||
}}
|
|
||||||
className="hover-glow"
|
|
||||||
>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{ width: '36px', height: '36px', borderRadius: '50%', background: 'rgba(59, 130, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent-cyan)', fontWeight: 700, fontSize: '0.875rem', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
|
||||||
{s.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700 }}>{s.name}</div>
|
<h1 style={{ fontSize: '1.6rem', fontWeight: 900, color: '#0F172A', margin: '0 0 10px' }}>{s.name}</h1>
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>ID: {s.id}</div>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ padding: '4px 12px', borderRadius: 7, fontSize: '0.72rem', fontWeight: 800, background: rc.bg, color: rc.color, border: `1px solid ${rc.color}40` }}>{s.role}</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 12px', borderRadius: 7, fontSize: '0.72rem', fontWeight: 700, background: `${sc.color}18`, color: sc.color }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: sc.color, display: 'inline-block', boxShadow: `0 0 6px ${sc.color}` }} />{sc.label}
|
||||||
|
</span>
|
||||||
|
{s.isAvailable && <span style={{ padding: '4px 12px', borderRadius: 7, fontSize: '0.72rem', fontWeight: 700, background: 'rgba(16,185,129,0.12)', color: '#10B981', border: '1px solid rgba(16,185,129,0.2)' }}>✓ Available</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<div style={{ fontSize: '0.8125rem', fontWeight: 600 }}>{s.role}</div>
|
|
||||||
{s.specialization && <div style={{ fontSize: '0.65rem', opacity: 0.5 }}>{s.specialization}</div>}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
||||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: s.status === 'ON_DUTY' ? '#22C55E' : s.status === 'ON_LEAVE' ? '#EF4444' : '#94A3B8' }}></div>
|
|
||||||
<span style={{ fontSize: '0.7rem', fontWeight: 700, opacity: 0.8 }}>{s.status.replace('_', ' ')}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<div style={{ fontWeight: 800 }}>{s.tripsCompleted}</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px' }}>
|
|
||||||
<button className="btn-ghost-sm"><MoreVertical size={14} /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ position: 'sticky', top: '0' }}>
|
|
||||||
{selectedStaff ? (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={selectedStaff.id}
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
|
||||||
<div style={{ width: '80px', height: '80px', borderRadius: '50%', background: 'rgba(59, 130, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent-cyan)', fontSize: '1.5rem', fontWeight: 900, border: '2px solid var(--accent-cyan)', margin: '0 auto 16px', boxShadow: '0 0 20px rgba(59, 130, 246, 0.2)' }}>
|
|
||||||
{selectedStaff.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 800 }}>{selectedStaff.name}</h2>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--accent-cyan)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{selectedStaff.role}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '24px' }}>
|
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5, textTransform: 'uppercase', marginBottom: '4px' }}>Trips Rate</div>
|
|
||||||
<div style={{ fontSize: '1.125rem', fontWeight: 800, color: 'var(--accent-green)' }}>{selectedStaff.rating}/5.0</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5, textTransform: 'uppercase', marginBottom: '4px' }}>SLA Compliance</div>
|
|
||||||
<div style={{ fontSize: '1.125rem', fontWeight: 800 }}>98.4%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: '0.7rem', fontWeight: 800, textTransform: 'uppercase', opacity: 0.5, marginBottom: '8px' }}>Contact Information</h4>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '0.8125rem' }}>
|
|
||||||
<Phone size={14} style={{ opacity: 0.5 }} /> {selectedStaff.phone}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '0.8125rem' }}>
|
|
||||||
<Mail size={14} style={{ opacity: 0.5 }} /> {selectedStaff.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Grid */}
|
||||||
<h4 style={{ fontSize: '0.7rem', fontWeight: 800, textTransform: 'uppercase', opacity: 0.5, marginBottom: '8px' }}>Certifications</h4>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(245, 158, 11, 0.05)', border: '1px solid rgba(245, 158, 11, 0.1)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 700 }}>Professional License</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.6 }}>Expiry: {selectedStaff.certExpiry}</div>
|
|
||||||
</div>
|
|
||||||
<AlertTriangle size={16} color="#F59E0B" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="btn-primary" style={{ width: '100%', marginTop: '24px' }}>MANAGE SHIFT SCHEDULE</button>
|
{/* Contact */}
|
||||||
</Card>
|
<div style={{ background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: 16, padding: 22, boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
</motion.div>
|
<div style={{ fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 14 }}>📞 Contact</div>
|
||||||
</AnimatePresence>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
) : (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', background: '#F8FAFC', border: '1px solid rgba(15, 23, 42, 0.06)', borderRadius: 10 }}>
|
||||||
<div className="glass" style={{ padding: '40px', borderRadius: '16px', textAlign: 'center', border: '1px dashed rgba(255,255,255,0.1)' }}>
|
<Phone size={16} color="#06B6D4" />
|
||||||
<Users size={32} style={{ opacity: 0.2, margin: '0 auto 16px' }} />
|
<div><div style={{ fontSize: '0.62rem', color: '#475569', marginBottom: 2 }}>Phone</div><div style={{ fontSize: '0.9rem', color: '#0F172A', fontWeight: 600 }}>{s.phone}</div></div>
|
||||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '8px' }}>Select Personnel</h3>
|
</div>
|
||||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>View detailed performance metrics, licensing status, and shift history for your fleet crew.</p>
|
{s.email && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', background: '#F8FAFC', border: '1px solid rgba(15, 23, 42, 0.06)', borderRadius: 10 }}>
|
||||||
|
<Mail size={16} color="#06B6D4" />
|
||||||
|
<div><div style={{ fontSize: '0.62rem', color: '#475569', marginBottom: 2 }}>Email</div><div style={{ fontSize: '0.88rem', color: '#0F172A', fontWeight: 600 }}>{s.email}</div></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Identity */}
|
||||||
|
<div style={{ background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: 16, padding: 22, boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 14 }}>🪪 Identity</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{row('Staff ID', s.id, true)}
|
||||||
|
{selectedRaw.aadhaar_number && row('Aadhaar', selectedRaw.aadhaar_number, true)}
|
||||||
|
{row('Joined Date', s.joinedDate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Professional — full width */}
|
||||||
|
{pd && (
|
||||||
|
<div style={{ gridColumn: '1 / -1', background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: 16, padding: 22, boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 14 }}>🎓 Professional Details</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(240px,1fr))', gap: 10 }}>
|
||||||
|
{pd.qualification && row('Qualification', pd.qualification)}
|
||||||
|
{pd.certification_body && row('Certification Body', pd.certification_body)}
|
||||||
|
{(pd.certificate_number || pd.license_number) && row('Cert / License No.', pd.certificate_number || pd.license_number || '', true)}
|
||||||
|
{pd.license_category && row('License Category', pd.license_category)}
|
||||||
|
{s.specialization && row('Specialization', s.specialization)}
|
||||||
|
{s.certExpiry && (
|
||||||
|
<div style={{ padding: '14px 16px', background: cw ? 'rgba(245,158,11,0.08)' : 'rgba(16,185,129,0.07)', border: `1px solid ${cw ? 'rgba(245,158,11,0.25)' : 'rgba(16,185,129,0.2)'}`, borderRadius: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#475569', marginBottom: 5, display: 'flex', alignItems: 'center', gap: 5, textTransform: 'uppercase' }}>
|
||||||
|
<ShieldCheck size={11} color={cw ? '#F59E0B' : '#10B981'} /> Cert Expiry
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.95rem', fontWeight: 800, color: cw ? '#F59E0B' : '#10B981' }}>{s.certExpiry}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}`}</style>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TABLE VIEW ──
|
||||||
|
return (
|
||||||
|
<div style={{ color: '#F8FAFC' }}>
|
||||||
|
|
||||||
|
{/* ── Add Staff Modal ── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModal && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(15, 23, 42, 0.3)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, backdropFilter: 'blur(6px)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) setShowModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div initial={{ scale: 0.93, y: 20, opacity: 0 }} animate={{ scale: 1, y: 0, opacity: 1 }} exit={{ scale: 0.93, y: 20, opacity: 0 }}
|
||||||
|
style={{ background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: 24, width: 780, maxWidth: '96vw', position: 'relative', boxShadow: '0 20px 50px rgba(15, 23, 42, 0.12)' }}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div style={{ background: 'linear-gradient(135deg,rgba(6,182,212,0.08),transparent)', borderBottom: '1px solid rgba(15, 23, 42, 0.08)', padding: '20px 28px 16px', borderRadius: '24px 24px 0 0' }}>
|
||||||
|
<button onClick={() => setShowModal(false)} style={{ position: 'absolute', top: 14, right: 14, background: '#F1F5F9', border: '1px solid #E2E8F0', borderRadius: 8, padding: 6, color: '#475569', cursor: 'pointer', display: 'flex' }}><X size={15} /></button>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 900, color: '#0F172A' }}>Register New Staff</h3>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '0.75rem', color: '#475569' }}>Fill details based on staff type</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '20px 28px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
|
{/* Type Selector */}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{(['DRIVER','EMT','DOCTOR'] as const).map(t => {
|
||||||
|
const rc = ROLE_COLORS[t];
|
||||||
|
return (
|
||||||
|
<button key={t} onClick={() => setStaffType(t)}
|
||||||
|
style={{ flex: 1, padding: '9px 0', borderRadius: 10, border: `2px solid ${staffType === t ? rc.color : '#E2E8F0'}`, background: staffType === t ? rc.bg : '#F1F5F9', color: staffType === t ? rc.color : '#475569', fontWeight: 800, fontSize: '0.8rem', cursor: 'pointer', transition: 'all 0.2s' }}>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Common Fields — 2 col grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{[{ k: 'name', label: 'Full Name', ph: 'e.g. Driver 2' }, { k: 'phone', label: 'Phone', ph: '9999999998' }, { k: 'aadhaar_number', label: 'Aadhaar Number', ph: '1234-5678-9001' }].map(f => (
|
||||||
|
<div key={f.k}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 5 }}>{f.label}</div>
|
||||||
|
<input value={(form as Record<string, string | boolean>)[f.k] as string} onChange={e => setF(f.k, e.target.value)} placeholder={f.ph}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ width: '100%', padding: '9px 12px', background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: 9, color: '#0F172A', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Password — same row as Aadhaar */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, marginBottom: 5 }}>Password <span style={{ color: '#64748B', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input type={showPw ? 'text' : 'password'} value={form.password} onChange={e => setF('password', e.target.value)} placeholder="Min 8 chars"
|
||||||
|
autoComplete="new-password"
|
||||||
|
style={{ width: '100%', padding: '9px 36px 9px 12px', background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: 9, color: '#0F172A', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
<button onClick={() => setShowPw(p => !p)} style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', color: '#475569', cursor: 'pointer', display: 'flex' }}>
|
||||||
|
{showPw ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DRIVER fields */}
|
||||||
|
{staffType === 'DRIVER' && (
|
||||||
|
<div style={{ padding: '14px 16px', background: 'rgba(6,182,212,0.03)', border: '1px solid rgba(6,182,212,0.15)', borderRadius: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#06B6D4', textTransform: 'uppercase', fontWeight: 700, marginBottom: 10 }}>Driver Professional Details</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
{[{ k:'license_number', label:'License Number', ph:'DL-KA-2024-DR1' }, { k:'license_category', label:'License Category', ph:'MCWG/LMV' }, { k:'license_expiry', label:'License Expiry', ph:'2034-01-01' }].map(f => (
|
||||||
|
<div key={f.k}>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#475569', textTransform: 'uppercase', fontWeight: 700, marginBottom: 4 }}>{f.label}</div>
|
||||||
|
<input value={(form as Record<string, string | boolean>)[f.k] as string} onChange={e => setF(f.k, e.target.value)} placeholder={f.ph}
|
||||||
|
style={{ width: '100%', padding: '8px 10px', background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: 8, color: '#0F172A', fontSize: '0.82rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EMT fields */}
|
||||||
|
{staffType === 'EMT' && (
|
||||||
|
<div style={{ padding: '14px 16px', background: 'rgba(59,130,246,0.03)', border: '1px solid rgba(59,130,246,0.15)', borderRadius: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#3B82F6', textTransform: 'uppercase', fontWeight: 700, marginBottom: 10 }}>EMT Professional Details</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{[{ k:'qualification', label:'Qualification', ph:'Diploma in EMT' }, { k:'certification_body', label:'Certification Body', ph:'Indian Resuscitation Council' }, { k:'certificate_number', label:'Certificate Number', ph:'EMT-CERT-9901' }, { k:'certificate_expiry', label:'Certificate Expiry', ph:'2029-08-15' }].map(f => (
|
||||||
|
<div key={f.k}>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#475569', textTransform: 'uppercase', fontWeight: 700, marginBottom: 4 }}>{f.label}</div>
|
||||||
|
<input value={(form as Record<string, string | boolean>)[f.k] as string} onChange={e => setF(f.k, e.target.value)} placeholder={f.ph}
|
||||||
|
style={{ width: '100%', padding: '8px 10px', background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: 8, color: '#0F172A', fontSize: '0.82rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DOCTOR fields */}
|
||||||
|
{staffType === 'DOCTOR' && (
|
||||||
|
<div style={{ padding: '14px 16px', background: 'rgba(16,185,129,0.03)', border: '1px solid rgba(16,185,129,0.15)', borderRadius: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#10B981', textTransform: 'uppercase', fontWeight: 700, marginBottom: 10 }}>Doctor Professional Details</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{[{ k:'medical_registration_number', label:'Medical Reg. Number', ph:'MCI/DMC/2024/77' }, { k:'specialization', label:'Specialization', ph:'Emergency Medicine' }, { k:'qualification', label:'Qualification', ph:'MBBS, MD (Emergency)' }].map(f => (
|
||||||
|
<div key={f.k}>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#475569', textTransform: 'uppercase', fontWeight: 700, marginBottom: 4 }}>{f.label}</div>
|
||||||
|
<input value={(form as Record<string, string | boolean>)[f.k] as string} onChange={e => setF(f.k, e.target.value)} placeholder={f.ph}
|
||||||
|
style={{ width: '100%', padding: '8px 10px', background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: 8, color: '#0F172A', fontSize: '0.82rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.82rem', color: '#475569', gridColumn: '1 / -1' }}>
|
||||||
|
<input type="checkbox" checked={form.teleconsult_available} onChange={e => setF('teleconsult_available', e.target.checked)} style={{ accentColor: '#10B981', width: 15, height: 15 }} />
|
||||||
|
Teleconsult Available
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitErr && <div style={{ padding: '9px 14px', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: 9, color: '#EF4444', fontSize: '0.8rem' }}>{submitErr}</div>}
|
||||||
|
{submitOk && <div style={{ padding: '9px 14px', background: 'rgba(16,185,129,0.1)', border: '1px solid rgba(16,185,129,0.2)', borderRadius: 9, color: '#10B981', fontSize: '0.8rem', fontWeight: 700 }}>✓ {submitOk}</div>}
|
||||||
|
|
||||||
|
<button onClick={submitStaff} disabled={submitting}
|
||||||
|
style={{ padding: '11px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 11, color: '#fff', fontWeight: 800, fontSize: '0.88rem', cursor: submitting ? 'not-allowed' : 'pointer', opacity: submitting ? 0.7 : 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||||
|
{submitting ? <><Loader2 size={15} style={{ animation: 'spin 1s linear infinite' }} /> Registering...</> : <><UserPlus size={15} /> Register {staffType}</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</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 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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, background: '#E2E8F0', padding: 4, borderRadius: 12, border: '1px solid #CBD5E1' }}>
|
||||||
|
{FILTERS.map(f => (
|
||||||
|
<button key={f} onClick={() => { setFilter(f); setPage(1); }} style={{ padding: '7px 14px', borderRadius: 8, border: 'none', cursor: 'pointer', fontSize: '0.78rem', fontWeight: filter === f ? 700 : 500, background: filter === f ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : 'transparent', color: filter === f ? '#fff' : '#475569', transition: 'all 0.2s' }}>
|
||||||
|
{f === 'ALL' ? 'All' : f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isSearchFocused ? '1px solid #06B6D4' : '1px solid #CBD5E1',
|
||||||
|
boxShadow: isSearchFocused ? '0 0 0 3px rgba(6, 182, 212, 0.15)' : 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
width: '260px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<Search size={16} color={isSearchFocused ? "#06B6D4" : "#64748B"} style={{ transition: 'color 0.2s' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search staff..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
className="stations-search-input"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
paddingRight: search ? '24px' : '0'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setPage(1); }}
|
||||||
|
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>
|
||||||
|
<button onClick={() => { resetModal(); setShowModal(true); }} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '11px 22px', background: '#0F172A', border: 'none', borderRadius: '12px', color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem', whiteSpace: 'nowrap' }}>
|
||||||
|
<UserPlus size={18} /> ADD STAFF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200, gap: 12, color: '#64748B' }}><Loader2 size={24} style={{ animation: 'spin 1s linear infinite' }} /><span>Loading staff...</span></div>}
|
||||||
|
|
||||||
|
{!loading && fetchError && (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !fetchError && (
|
||||||
|
<div style={{ background: '#FFFFFF', border: '1px solid rgba(15, 23, 42, 0.08)', borderRadius: 16, overflow: 'hidden', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['#', 'Staff Member', 'Role', 'Status', 'Phone', 'Specialization', 'Joined', 'Cert Expiry'].map(h => <th key={h} style={th}>{h}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pageData.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 48, color: '#64748B' }}>{staffList.length === 0 ? 'No staff registered yet.' : 'No results match your filter.'}</td></tr>
|
||||||
|
) : pageData.map((s, idx) => {
|
||||||
|
const rc = ROLE_COLORS[s.role] || DEF;
|
||||||
|
const sc = STATUS_CFG[s.status] || { label: s.status, color: '#475569' };
|
||||||
|
const certSoon = s.certExpiry ? new Date(s.certExpiry) < new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) : false;
|
||||||
|
return (
|
||||||
|
<tr key={s.id}
|
||||||
|
onClick={() => { const raw = staffList.find(r => r.id === s.id); if (raw) setSelectedRaw(raw); }}
|
||||||
|
style={{ borderBottom: '1px solid rgba(15, 23, 42, 0.06)', cursor: 'pointer', transition: 'background 0.15s' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = '#F8FAFC')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '14px 18px', color: '#475569', fontSize: '0.78rem', fontWeight: 600 }}>{(safePage - 1) * itemsPerPage + idx + 1}</td>
|
||||||
|
<td style={{ padding: '14px 18px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ width: 38, height: 38, borderRadius: 10, background: rc.bg, border: `1.5px solid ${rc.color}40`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.85rem', fontWeight: 900, color: rc.color, flexShrink: 0 }}>
|
||||||
|
{s.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.88rem', color: '#0F172A' }}>{s.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '14px 18px' }}><span style={{ padding: '4px 10px', borderRadius: 6, fontSize: '0.68rem', fontWeight: 800, background: rc.bg, color: rc.color }}>{s.role || '—'}</span></td>
|
||||||
|
<td style={{ padding: '14px 18px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div style={{ width: 7, height: 7, borderRadius: '50%', background: sc.color, boxShadow: `0 0 6px ${sc.color}` }} />
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: sc.color }}>{sc.label}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '14px 18px' }}><div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.78rem', color: '#475569' }}><Phone size={12} color="#06B6D4" />{s.phone}</div></td>
|
||||||
|
<td style={{ padding: '14px 18px' }}><span style={{ fontSize: '0.78rem', color: '#475569' }}>{s.specialization || '—'}</span></td>
|
||||||
|
<td style={{ padding: '14px 18px' }}><span style={{ fontSize: '0.78rem', color: '#475569' }}>{s.joinedDate || '—'}</span></td>
|
||||||
|
<td style={{ padding: '14px 18px' }}>
|
||||||
|
{s.certExpiry ? <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><ShieldCheck size={14} color={certSoon ? '#F59E0B' : '#10B981'} /><span style={{ fontSize: '0.75rem', fontWeight: 600, color: certSoon ? '#F59E0B' : '#475569' }}>{s.certExpiry}</span></div> : <span style={{ color: '#64748B' }}>—</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .spin{animation:spin 1s linear infinite}`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,165 +1,558 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
|
||||||
Clock,
|
Clock,
|
||||||
Users,
|
|
||||||
Truck,
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle2,
|
|
||||||
Plus,
|
Plus,
|
||||||
ChevronLeft,
|
User,
|
||||||
ChevronRight,
|
Stethoscope,
|
||||||
MoreVertical,
|
Car,
|
||||||
Navigation,
|
X,
|
||||||
ShieldAlert
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Activity,
|
||||||
|
Calendar
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card } from '../../components/Common';
|
import { fleetApi } from '../../api/fleet';
|
||||||
|
|
||||||
|
// --- INTERFACES ---
|
||||||
interface Assignment {
|
interface Assignment {
|
||||||
id: string;
|
id: string;
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
|
vehicleReg?: string;
|
||||||
|
vehicleType: 'ALS' | 'BLS' | 'TRANS';
|
||||||
shift: 'MORNING' | 'EVENING' | 'NIGHT';
|
shift: 'MORNING' | 'EVENING' | 'NIGHT';
|
||||||
driver: string;
|
driver: string;
|
||||||
emt: string;
|
emt: string;
|
||||||
doctor?: string;
|
doctor?: string;
|
||||||
status: 'SCHEDULED' | 'ON_DUTY' | 'HANDOVER_PENDING';
|
status: 'SCHEDULED' | 'ON_DUTY';
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_ASSIGNMENTS: Assignment[] = [
|
interface APIVehicle {
|
||||||
{ id: 'AS-1001', vehicleId: 'V-001 (ALS)', shift: 'MORNING', driver: 'Vikram Singh', emt: 'Rahul Verma', doctor: 'Dr. Ananya Iyer', status: 'ON_DUTY', startTime: '06:00', endTime: '14:00' },
|
id: string;
|
||||||
{ id: 'AS-1002', vehicleId: 'V-002 (BLS)', shift: 'MORNING', driver: 'Suresh Kumar', emt: 'Amit Roy', status: 'ON_DUTY', startTime: '06:00', endTime: '14:00' },
|
registration_number: string;
|
||||||
{ id: 'AS-1003', vehicleId: 'V-003 (ALS)', shift: 'EVENING', driver: 'Karan Mehra', emt: 'Priya Das', doctor: 'Dr. Sameer Gupta', status: 'SCHEDULED', startTime: '14:00', endTime: '22:00' },
|
vehicle_type: string;
|
||||||
{ id: 'AS-1004', vehicleId: 'V-004 (TRANS)', shift: 'MORNING', driver: 'Ravi Teja', emt: 'Sneha Rao', status: 'HANDOVER_PENDING', startTime: '06:00', endTime: '14:00' },
|
status?: string;
|
||||||
];
|
}
|
||||||
|
|
||||||
|
interface APIStaff {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status?: string;
|
||||||
|
user?: {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIFTS = [
|
||||||
|
{ key: 'MORNING', label: 'Morning', time: '06:00 – 14:00', color: '#F59E0B', bg: 'rgba(245,158,11,0.1)' },
|
||||||
|
{ key: 'EVENING', label: 'Evening', time: '14:00 – 22:00', color: '#3B82F6', bg: 'rgba(59,130,246,0.1)' },
|
||||||
|
{ key: 'NIGHT', label: 'Night', time: '22:00 – 06:00', color: '#8B5CF6', bg: 'rgba(139,92,246,0.1)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const FleetScheduling: React.FC = () => {
|
export const FleetScheduling: React.FC = () => {
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
const navigate = useNavigate();
|
||||||
|
// --- STATE ---
|
||||||
|
const [selectedDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||||
|
const [view, setView] = useState<'DAY' | 'WEEK' | 'MONTH'>('DAY');
|
||||||
|
|
||||||
|
// Roster lists & API state
|
||||||
|
const [apiVehicles, setApiVehicles] = useState<APIVehicle[]>([]);
|
||||||
|
const [apiStaff, setApiStaff] = useState<APIStaff[]>([]);
|
||||||
|
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||||
|
const [submittingRoster, setSubmittingRoster] = useState(false);
|
||||||
|
const [rosterSuccess, setRosterSuccess] = useState('');
|
||||||
|
const [rosterErr, setRosterErr] = useState('');
|
||||||
|
|
||||||
|
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
||||||
|
|
||||||
|
// Modals state
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
// Form states for creating dynamic Roster
|
||||||
|
const [formVehicleId, setFormVehicleId] = useState('');
|
||||||
|
const [formDriverId, setFormDriverId] = useState('');
|
||||||
|
const [formStaffId, setFormStaffId] = useState('');
|
||||||
|
const [formStartDate, setFormStartDate] = useState('');
|
||||||
|
const [formEndDate, setFormEndDate] = useState('');
|
||||||
|
const [formShiftType, setFormShiftType] = useState<'DAY' | 'NIGHT' | 'SPLIT' | ''>('');
|
||||||
|
const [formNotes, setFormNotes] = useState('');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
|
// --- FETCH OPERATOR ASSETS (Vehicles & Staff) ---
|
||||||
|
const fetchAssets = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoadingAssets(true);
|
||||||
|
try {
|
||||||
|
// 1. Fetch Vehicles
|
||||||
|
const vJson = await fleetApi.getVehicles(token);
|
||||||
|
let vList: APIVehicle[] = [];
|
||||||
|
if (vJson?.data?.data && Array.isArray(vJson.data.data)) vList = vJson.data.data;
|
||||||
|
else if (vJson?.data && Array.isArray(vJson.data)) vList = vJson.data;
|
||||||
|
else if (Array.isArray(vJson)) vList = vJson;
|
||||||
|
setApiVehicles(vList);
|
||||||
|
|
||||||
|
// 2. Fetch Staff
|
||||||
|
const sJson = await fleetApi.getStaff(token);
|
||||||
|
let sList: APIStaff[] = [];
|
||||||
|
if (sJson?.data?.data && Array.isArray(sJson.data.data)) sList = sJson.data.data;
|
||||||
|
else if (sJson?.data && Array.isArray(sJson.data)) sList = sJson.data;
|
||||||
|
else if (Array.isArray(sJson)) sList = sJson;
|
||||||
|
setApiStaff(sList);
|
||||||
|
|
||||||
|
// 3. Fetch Scheduled Rosters
|
||||||
|
let rList: any[] = [];
|
||||||
|
try {
|
||||||
|
const rJson = await fleetApi.getRoster(token);
|
||||||
|
if (rJson) {
|
||||||
|
rList = rJson?.data?.data || rJson?.data || (Array.isArray(rJson) ? rJson : []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch roster list:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Map to assignments (using actual data only, completely removing mocks)
|
||||||
|
const parsedAssignments: Assignment[] = [];
|
||||||
|
const addedIds = new Set<string>();
|
||||||
|
|
||||||
|
// Add from roster query
|
||||||
|
rList.forEach((r: any) => {
|
||||||
|
if (!r.id) return;
|
||||||
|
const vehicle = r.vehicle || vList.find((v: any) => v.id === r.vehicleId);
|
||||||
|
const driverName = r.driver?.user?.name || 'Unknown Pilot';
|
||||||
|
const staffName = r.staff?.user?.name || 'Unknown EMT';
|
||||||
|
|
||||||
|
parsedAssignments.push({
|
||||||
|
id: r.id,
|
||||||
|
date: r.startDate || new Date().toISOString().split('T')[0],
|
||||||
|
vehicleId: r.vehicleId,
|
||||||
|
vehicleReg: vehicle?.registration_number || r.vehicleId,
|
||||||
|
vehicleType: (vehicle?.vehicle_type as any) || 'ALS',
|
||||||
|
shift: r.shiftType === 'NIGHT' ? 'NIGHT' : (r.shiftType === 'DAY' ? 'MORNING' : 'EVENING'),
|
||||||
|
driver: driverName,
|
||||||
|
emt: staffName,
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
startTime: r.shiftType === 'NIGHT' ? '22:00' : (r.shiftType === 'DAY' ? '06:00' : '14:00'),
|
||||||
|
endTime: r.shiftType === 'NIGHT' ? '06:00' : (r.shiftType === 'DAY' ? '14:00' : '22:00')
|
||||||
|
});
|
||||||
|
addedIds.add(r.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active rosters from live vehicles list
|
||||||
|
vList.forEach((v: any) => {
|
||||||
|
if (v.activeRoster && v.activeRoster.id && !addedIds.has(v.activeRoster.id)) {
|
||||||
|
const r = v.activeRoster;
|
||||||
|
const driverName = r.driver?.user?.name || 'Unknown Pilot';
|
||||||
|
const staffName = r.staff?.user?.name || 'Unknown EMT';
|
||||||
|
|
||||||
|
parsedAssignments.push({
|
||||||
|
id: r.id,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
vehicleId: v.id,
|
||||||
|
vehicleReg: v.registration_number,
|
||||||
|
vehicleType: (v.vehicle_type as any) || 'ALS',
|
||||||
|
shift: 'MORNING',
|
||||||
|
driver: driverName,
|
||||||
|
emt: staffName,
|
||||||
|
status: 'ON_DUTY',
|
||||||
|
startTime: '06:00',
|
||||||
|
endTime: '14:00'
|
||||||
|
});
|
||||||
|
addedIds.add(r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.activeShift && v.activeShift.id && !addedIds.has(v.activeShift.id)) {
|
||||||
|
const sh = v.activeShift;
|
||||||
|
const driverName = sh.driver?.user?.name || 'Unknown Pilot';
|
||||||
|
const staffName = sh.staff?.user?.name || 'Unknown EMT';
|
||||||
|
|
||||||
|
parsedAssignments.push({
|
||||||
|
id: sh.id,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
vehicleId: v.id,
|
||||||
|
vehicleReg: v.registration_number,
|
||||||
|
vehicleType: (v.vehicle_type as any) || 'ALS',
|
||||||
|
shift: 'EVENING',
|
||||||
|
driver: driverName,
|
||||||
|
emt: staffName,
|
||||||
|
status: 'ON_DUTY',
|
||||||
|
startTime: '14:00',
|
||||||
|
endTime: '22:00'
|
||||||
|
});
|
||||||
|
addedIds.add(sh.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAssignments(parsedAssignments);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Scheduling API Check] Failed to sync assets:', e);
|
||||||
|
} finally {
|
||||||
|
setLoadingAssets(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAssets();
|
||||||
|
}, [fetchAssets]);
|
||||||
|
|
||||||
|
const driverOptions = useMemo(() => {
|
||||||
|
return apiStaff.filter(s => s.type === 'DRIVER').map(s => ({ id: s.id, name: s.user?.name || 'Unknown Pilot' }));
|
||||||
|
}, [apiStaff]);
|
||||||
|
|
||||||
|
const emtOptions = useMemo(() => {
|
||||||
|
return apiStaff.filter(s => s.type === 'EMT').map(s => ({ id: s.id, name: s.user?.name || 'Unknown EMT' }));
|
||||||
|
}, [apiStaff]);
|
||||||
|
|
||||||
|
const doctorOptions = useMemo(() => {
|
||||||
|
return apiStaff.filter(s => s.type === 'DOCTOR').map(s => ({ id: s.id, name: s.user?.name || 'Unknown Doctor' }));
|
||||||
|
}, [apiStaff]);
|
||||||
|
|
||||||
|
const vehicleOptions = useMemo(() => {
|
||||||
|
return apiVehicles.map(v => ({ id: v.id, registration_number: v.registration_number, vehicle_type: v.vehicle_type || 'ALS' }));
|
||||||
|
}, [apiVehicles]);
|
||||||
|
|
||||||
|
const currentFilteredAssignments = useMemo(() => {
|
||||||
|
return assignments.filter(as => {
|
||||||
|
if (view === 'DAY') {
|
||||||
|
return as.date === selectedDate;
|
||||||
|
} else if (view === 'WEEK') {
|
||||||
|
const dateDiff = Math.abs(new Date(as.date).getTime() - new Date(selectedDate).getTime());
|
||||||
|
const daysDiff = dateDiff / (1000 * 3600 * 24);
|
||||||
|
return daysDiff <= 3;
|
||||||
|
} else {
|
||||||
|
return as.date.substring(0, 7) === selectedDate.substring(0, 7);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [assignments, selectedDate, view]);
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setFormVehicleId('');
|
||||||
|
setFormDriverId('');
|
||||||
|
setFormStaffId('');
|
||||||
|
setFormStartDate('');
|
||||||
|
setFormEndDate('');
|
||||||
|
setFormShiftType('');
|
||||||
|
setFormNotes('');
|
||||||
|
setRosterErr('');
|
||||||
|
setRosterSuccess('');
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAssignment = async () => {
|
||||||
|
setRosterErr('');
|
||||||
|
setRosterSuccess('');
|
||||||
|
|
||||||
|
if (!formVehicleId) {
|
||||||
|
setRosterErr('Please select an Ambulance Unit.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formShiftType) {
|
||||||
|
setRosterErr('Please select a Shift Type.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formDriverId) {
|
||||||
|
setRosterErr('Please select a Driver.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formStaffId) {
|
||||||
|
setRosterErr('Please select a Staff Member (EMT/Doctor).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formStartDate || !formEndDate) {
|
||||||
|
setRosterErr('Please select both Start Date and End Date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUuid = (str: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
||||||
|
|
||||||
|
if (!isUuid(formVehicleId) || !isUuid(formDriverId) || !isUuid(formStaffId)) {
|
||||||
|
setRosterErr('Selected items must be valid backend UUIDs. Please register assets first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
vehicleId: formVehicleId,
|
||||||
|
driverId: formDriverId,
|
||||||
|
staffId: formStaffId,
|
||||||
|
startDate: formStartDate,
|
||||||
|
endDate: formEndDate,
|
||||||
|
shiftType: formShiftType,
|
||||||
|
notes: formNotes || 'Fleet Deployment Roster'
|
||||||
|
};
|
||||||
|
|
||||||
|
setSubmittingRoster(true);
|
||||||
|
try {
|
||||||
|
const json = await fleetApi.createRoster(payload, token);
|
||||||
|
|
||||||
|
if (json && json.status && json.status >= 400) {
|
||||||
|
throw new Error(json?.message || `API Response Error ${json.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRosterSuccess('Roster scheduled successfully on backend API!');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchAssets(); // Refresh assignments from real database endpoints
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setRosterSuccess('');
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Create Roster Error]:', err);
|
||||||
|
setRosterErr(err?.message || 'Failed to schedule roster assignment on backend.');
|
||||||
|
} finally {
|
||||||
|
setSubmittingRoster(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fleet-scheduling animate-in fade-in duration-500">
|
<div style={{ color: '#F8FAFC', fontFamily: 'Inter, sans-serif' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
{/* ── Header Toolbar (View selection controls & create button) ── */}
|
||||||
<div className="glass" style={{ padding: '8px 16px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '12px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<button className="btn-ghost-sm" style={{ padding: '4px' }}><ChevronLeft size={16} /></button>
|
|
||||||
<span style={{ fontWeight: 800, fontSize: '0.875rem' }}>{selectedDate}</span>
|
{/* View Mode Toggle (Daily, Weekly, Monthly) */}
|
||||||
<button className="btn-ghost-sm" style={{ padding: '4px' }}><ChevronRight size={16} /></button>
|
<div style={{ display: 'flex', gap: 4, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 10, padding: 3 }}>
|
||||||
</div>
|
{(['DAY', 'WEEK', 'MONTH'] as const).map(v => (
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<button key={v} onClick={() => setView(v)}
|
||||||
{['DAY', 'WEEK', 'MONTH'].map(v => (
|
style={{ padding: '8px 16px', borderRadius: 8, border: 'none', fontSize: '0.78rem', fontWeight: 700, cursor: 'pointer', background: view === v ? 'linear-gradient(135deg,#06B6D4,#3B82F6)' : 'transparent', color: view === v ? '#fff' : '#64748B', transition: 'all 0.2s' }}>
|
||||||
<button key={v} style={{ fontSize: '0.65rem', fontWeight: 900, padding: '6px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.05)', background: v === 'DAY' ? 'var(--accent-cyan)' : 'transparent', color: v === 'DAY' ? '#000' : 'var(--text-secondary)' }}>{v}</button>
|
{v === 'DAY' ? 'Daily' : v === 'WEEK' ? 'Weekly' : 'Monthly'}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
{/* Action Buttons */}
|
||||||
<Plus size={18} /> CREATE NEW ASSIGNMENT
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={() => navigate('?tab=active-shifts')} 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: '#10B981', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer' }}>
|
||||||
|
<Activity size={13} /> Active Shifts
|
||||||
|
</button>
|
||||||
|
<button onClick={fetchAssets} 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' }}>
|
||||||
|
{loadingAssets ? <Loader2 size={13} className="spin" /> : '↺ Sync Assets'}
|
||||||
|
</button>
|
||||||
|
<button onClick={openCreateModal} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 20px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 12, color: '#fff', fontWeight: 800, fontSize: '0.82rem', cursor: 'pointer', boxShadow: '0 4px 14px rgba(6,182,212,0.3)', whiteSpace: 'nowrap' }}>
|
||||||
|
<Plus size={16} /> NEW CREW ASSIGNMENT
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px' }}>
|
|
||||||
{/* Mission Roster Grid */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<Card title="Shift Roster Matrix">
|
|
||||||
<div className="table-container">
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ textAlign: 'left', opacity: 0.5, fontSize: '0.65rem', textTransform: 'uppercase', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
<th style={{ padding: '12px' }}>Time Slot</th>
|
|
||||||
<th style={{ padding: '12px' }}>Vehicle</th>
|
|
||||||
<th style={{ padding: '12px' }}>Assigned Crew</th>
|
|
||||||
<th style={{ padding: '12px' }}>Status</th>
|
|
||||||
<th style={{ padding: '12px' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{MOCK_ASSIGNMENTS.map(as => (
|
|
||||||
<tr key={as.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ fontWeight: 800, fontSize: '0.875rem', color: 'var(--accent-cyan)' }}>{as.startTime} - {as.endTime}</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>{as.shift} SHIFT</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<Truck size={14} style={{ opacity: 0.5 }} />
|
|
||||||
<span style={{ fontWeight: 700 }}>{as.vehicleId}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
{/* ── Roster Grid ── */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 600 }}>P: {as.driver}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', opacity: 0.8 }}>E: {as.emt}</div>
|
{/* Count summary label */}
|
||||||
{as.doctor && <div style={{ fontSize: '0.75rem', color: 'var(--accent-green)' }}>D: {as.doctor}</div>}
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||||
|
Showing {currentFilteredAssignments.length} scheduled slots ({view === 'DAY' ? 'Today' : view === 'WEEK' ? 'Weekly Outlook' : 'Monthly'})
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
{/* Assignments Roster List */}
|
||||||
<span style={{
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
fontSize: '0.6rem',
|
{currentFilteredAssignments.length === 0 ? (
|
||||||
fontWeight: 900,
|
<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 }}>
|
||||||
padding: '4px 8px',
|
<Calendar size={36} color="#64748B" style={{ marginBottom: 12 }} />
|
||||||
borderRadius: '4px',
|
<p style={{ color: '#64748B', fontSize: '0.88rem', margin: 0 }}>No crew roster scheduled for the selected view criteria.</p>
|
||||||
background: as.status === 'ON_DUTY' ? 'rgba(34, 197, 94, 0.1)' : as.status === 'HANDOVER_PENDING' ? 'rgba(245, 158, 11, 0.1)' : 'rgba(148, 163, 184, 0.1)',
|
</div>
|
||||||
color: as.status === 'ON_DUTY' ? '#22C55E' : as.status === 'HANDOVER_PENDING' ? '#F59E0B' : '#94A3B8',
|
) : (
|
||||||
border: `1px solid ${as.status === 'ON_DUTY' ? 'rgba(34, 197, 94, 0.2)' : as.status === 'HANDOVER_PENDING' ? 'rgba(245, 158, 11, 0.2)' : 'rgba(148, 163, 184, 0.2)'}`
|
currentFilteredAssignments.map((a, i) => {
|
||||||
}}>{as.status.replace('_', ' ')}</span>
|
const sh = SHIFTS.find(s => s.key === a.shift) || SHIFTS[0];
|
||||||
</td>
|
|
||||||
<td style={{ padding: '16px 12px' }}>
|
return (
|
||||||
<button className="btn-ghost-sm"><MoreVertical size={14} /></button>
|
<motion.div key={a.id} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.04 }}
|
||||||
</td>
|
style={{
|
||||||
</tr>
|
background: 'rgba(255,255,255,0.02)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.07)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '18px 24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Shift Badge */}
|
||||||
|
<div style={{ padding: '5px 10px', background: sh.bg, border: `1px solid ${sh.color}30`, borderRadius: 7, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Clock size={11} color={sh.color} />
|
||||||
|
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: sh.color }}>{sh.label} Shift</span>
|
||||||
|
<span style={{ fontSize: '0.65rem', color: sh.color, opacity: 0.8 }}>{a.startTime}–{a.endTime}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date details */}
|
||||||
|
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: '#64748B', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Calendar size={12} /> {a.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Driver + EMT (+ doctor if ALS) mapping */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<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>
|
||||||
|
<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' }}>{a.driver}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>Emergency Medical Technician (EMT)</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#000000' }}>{a.emt}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{a.vehicleType === 'ALS' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'rgba(16,185,129,0.04)', border: '1px solid rgba(16,185,129,0.08)', borderRadius: 12, flex: 1, minWidth: 200 }}>
|
||||||
|
<Stethoscope size={15} color="#10B981" />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 700 }}>Advanced Medical Doctor</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#000000' }}>{a.doctor || 'Dr. Alok Mehta'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── CREATE ROSTER ASSIGNMENT MODAL ── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showCreateModal && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, backdropFilter: 'blur(6px)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget && !submittingRoster) setShowCreateModal(false); }}
|
||||||
|
>
|
||||||
|
<motion.div initial={{ scale: 0.93, y: 20, opacity: 0 }} animate={{ scale: 1, y: 0, opacity: 1 }} exit={{ scale: 0.93, y: 20, opacity: 0 }}
|
||||||
|
style={{ background: '#0D1526', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 24, width: 620, maxWidth: '95vw', position: 'relative' }}
|
||||||
|
>
|
||||||
|
<div style={{ background: 'linear-gradient(135deg,rgba(6,182,212,0.12),transparent)', borderBottom: '1px solid rgba(255,255,255,0.07)', padding: '20px 28px 16px', borderRadius: '24px 24px 0 0' }}>
|
||||||
|
<button onClick={() => setShowCreateModal(false)} disabled={submittingRoster} style={{ position: 'absolute', top: 14, right: 14, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, padding: 6, color: '#94A3B8', cursor: 'pointer', display: 'flex' }}><X size={15} /></button>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.05rem', fontWeight: 900, color: '#fff' }}>Deploy & Roster Crew</h3>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '0.75rem', color: '#64748B' }}>Assign Driver + EMT (+ Doctor if ALS) to a vehicle for a shift</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '20px 28px 24px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{/* Vehicle select */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Ambulance Unit</label>
|
||||||
|
<select value={formVehicleId} onChange={e => setFormVehicleId(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none' }}>
|
||||||
|
<option value="" style={{ background: '#0D1526' }}>Select Ambulance</option>
|
||||||
|
{vehicleOptions.map(v => (
|
||||||
|
<option key={v.id} value={v.id} style={{ background: '#0D1526' }}>{v.registration_number} ({v.vehicle_type})</option>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</select>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conflict & Handover Panel */}
|
{/* Shift Selection */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
|
||||||
<Card title="Conflict Engine" glowColor="amber">
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(245, 158, 11, 0.05)', border: '1px solid rgba(245, 158, 11, 0.1)', display: 'flex', gap: '12px' }}>
|
|
||||||
<ShieldAlert size={20} color="#F59E0B" />
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 800 }}>DOUBLE BOOKING DETECTED</div>
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Shift Type</label>
|
||||||
<p style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: '4px' }}>Amit Roy (EMT) assigned to V-002 and V-005 in Evening Shift.</p>
|
<select value={formShiftType} onChange={e => setFormShiftType(e.target.value as any)}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none' }}>
|
||||||
|
<option value="" style={{ background: '#0D1526' }}>-- Select Shift Type --</option>
|
||||||
|
<option value="DAY" style={{ background: '#0D1526' }}>DAY (Morning/Evening)</option>
|
||||||
|
<option value="NIGHT" style={{ background: '#0D1526' }}>NIGHT (Night Shift)</option>
|
||||||
|
<option value="SPLIT" style={{ background: '#0D1526' }}>SPLIT (On-Demand)</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px', borderRadius: '12px', background: 'rgba(239, 68, 68, 0.05)', border: '1px solid rgba(239, 68, 68, 0.1)', display: 'flex', gap: '12px' }}>
|
|
||||||
<AlertTriangle size={20} color="#EF4444" />
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 800 }}>CERTIFICATION EXPIRED</div>
|
|
||||||
<p style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: '4px' }}>Dr. Sameer Gupta license expired on 2026-05-01.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Shift Handover Tracker">
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
{/* Driver deploy */}
|
||||||
<div style={{ padding: '16px', borderRadius: '12px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Assign Driver</label>
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 700 }}>V-004 Handover Checklist</span>
|
<select value={formDriverId} onChange={e => setFormDriverId(e.target.value)}
|
||||||
<span style={{ fontSize: '0.65rem', color: '#F59E0B', fontWeight: 800 }}>4/6 TASKS</span>
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none' }}>
|
||||||
|
<option value="" style={{ background: '#0D1526' }}>-- Select Driver --</option>
|
||||||
|
{driverOptions.map(d => (
|
||||||
|
<option key={d.id} value={d.id} style={{ background: '#0D1526' }}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.8 }}>
|
{/* EMT/Staff deploy */}
|
||||||
<CheckCircle2 size={12} color="#22C55E" /> Fuel Tank Checked (100%)
|
<div>
|
||||||
</div>
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Assign Staff (EMT/Doctor)</label>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.8 }}>
|
<select value={formStaffId} onChange={e => setFormStaffId(e.target.value)}
|
||||||
<CheckCircle2 size={12} color="#22C55E" /> Oxygen Level Verified
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none' }}>
|
||||||
</div>
|
<option value="" style={{ background: '#0D1526' }}>-- Select Staff Member --</option>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.5 }}>
|
<optgroup label="Emergency Medical Techs" style={{ background: '#0D1526' }}>
|
||||||
<Clock size={12} /> Narcotics Inventory Counter-sign
|
{emtOptions.map(emt => (
|
||||||
|
<option key={emt.id} value={emt.id}>{emt.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="MD Physicians" style={{ background: '#0D1526' }}>
|
||||||
|
{doctorOptions.map(doc => (
|
||||||
|
<option key={doc.id} value={doc.id}>{doc.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
{/* Start Date */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Start Date</label>
|
||||||
|
<input type="date" value={formStartDate} onChange={e => setFormStartDate(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Date */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>End Date</label>
|
||||||
|
<input type="date" value={formEndDate} onChange={e => setFormEndDate(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '9px 12px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary" style={{ width: '100%', marginTop: '16px', fontSize: '0.75rem' }}>RESOLVE HANDOVERS</button>
|
|
||||||
</Card>
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 700, display: 'block', marginBottom: 6 }}>Roster Notes</label>
|
||||||
|
<input type="text" value={formNotes} onChange={e => setFormNotes(e.target.value)} placeholder="e.g. Active dispatch on V-001 ALS"
|
||||||
|
style={{ width: '100%', padding: '10px 14px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, color: '#fff', fontSize: '0.85rem', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{rosterErr && (
|
||||||
|
<div style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', padding: '10px 12px', borderRadius: 10, color: '#EF4444', fontSize: '0.75rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<AlertCircle size={14} /> {rosterErr}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rosterSuccess && (
|
||||||
|
<div style={{ background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.2)', padding: '10px 12px', borderRadius: 10, color: '#10B981', fontSize: '0.75rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Check size={14} /> {rosterSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={handleCreateAssignment} disabled={submittingRoster}
|
||||||
|
style={{ width: '100%', padding: '12px', background: 'linear-gradient(135deg,#06B6D4,#3B82F6)', border: 'none', borderRadius: 12, color: '#fff', fontWeight: 800, fontSize: '0.88rem', cursor: submittingRoster ? 'not-allowed' : 'pointer', marginTop: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||||
|
{submittingRoster ? <Loader2 size={16} className="spin" /> : 'FINALIZE ROSTER'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<style>{`@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .spin{animation:spin 1s linear infinite}`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
339
src/pages/fleet/FleetTrips.tsx
Normal file
339
src/pages/fleet/FleetTrips.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Search, MapPin, Truck, Activity, Navigation,
|
||||||
|
User, Calendar, Clock, AlertTriangle, Filter,
|
||||||
|
ChevronDown, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
interface Vehicle {
|
||||||
|
id: string;
|
||||||
|
registration_number: string;
|
||||||
|
vehicle_type: string;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
station_id?: string;
|
||||||
|
status?: string;
|
||||||
|
gps_lat?: string;
|
||||||
|
gps_lon?: string;
|
||||||
|
activeShift?: any;
|
||||||
|
activeRoster?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FleetTrips: React.FC = () => {
|
||||||
|
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const [page, setPage] = useState<number>(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
|
||||||
|
|
||||||
|
const fetchVehicles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/vehicles', {
|
||||||
|
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);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to fetch vehicles for trip management:', e);
|
||||||
|
setError(e?.message || 'Failed to fetch vehicles');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVehicles();
|
||||||
|
}, [fetchVehicles]);
|
||||||
|
|
||||||
|
const filteredVehicles = vehicles.filter(v => {
|
||||||
|
const searchMatch = v.registration_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
v.activeShift?.driver?.user?.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
v.activeShift?.staff?.user?.name?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const statusMatch = statusFilter ? v.status === statusFilter : true;
|
||||||
|
return searchMatch && statusMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredVehicles.length / itemsPerPage));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const pageData = filteredVehicles.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
|
||||||
|
|
||||||
|
const getStatusColor = (status?: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'BUSY':
|
||||||
|
return { bg: 'rgba(239, 68, 68, 0.1)', color: '#EF4444', border: 'rgba(239, 68, 68, 0.2)' };
|
||||||
|
case 'AVAILABLE':
|
||||||
|
return { bg: 'rgba(34, 197, 94, 0.1)', color: '#22C55E', border: 'rgba(34, 197, 94, 0.2)' };
|
||||||
|
default:
|
||||||
|
return { bg: 'rgba(245, 158, 11, 0.1)', color: '#F59E0B', border: 'rgba(245, 158, 11, 0.2)' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTripsCount = vehicles.filter(v => v.status === 'BUSY').length;
|
||||||
|
const availableCount = vehicles.filter(v => v.status === 'AVAILABLE').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fleet-trips 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)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||||
|
<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: '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: '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: '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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isSearchFocused ? '1px solid #06B6D4' : '1px solid #CBD5E1',
|
||||||
|
boxShadow: isSearchFocused ? '0 0 0 3px rgba(6, 182, 212, 0.15)' : '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<Search size={16} color={isSearchFocused ? "#06B6D4" : "#64748B"} style={{ transition: 'color 0.2s' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by vehicle reg, driver, or EMT name..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
className="stations-search-input"
|
||||||
|
style={{ background: 'transparent', border: 'none', color: '#0F172A', fontSize: '0.875rem', outline: 'none', width: '100%', paddingRight: searchQuery ? '24px' : '0' }}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearchQuery(''); setPage(1); }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '12px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#94A3B8',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '2px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#475569'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#94A3B8'}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||||
|
style={{
|
||||||
|
background: '#FFFFFF', border: '1px solid #CBD5E1', padding: '11px 36px 11px 16px',
|
||||||
|
borderRadius: '12px', color: '#0F172A', fontSize: '0.875rem', outline: 'none',
|
||||||
|
cursor: 'pointer', appearance: 'none', minWidth: '180px', boxShadow: '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="AVAILABLE">Available</option>
|
||||||
|
<option value="BUSY">Busy</option>
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={16} color="#64748B" style={{ position: 'absolute', right: '12px', top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '60px', textAlign: 'center', color: '#64748B', background: '#FFFFFF', borderRadius: '16px', border: '1px solid #CBD5E1' }}>
|
||||||
|
<Activity className="spin" size={24} style={{ margin: '0 auto 12px' }} />
|
||||||
|
Loading trips...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ padding: '24px', background: '#FEF2F2', border: '1px solid #FCA5A5', color: '#991B1B', borderRadius: '16px' }}>
|
||||||
|
<AlertTriangle size={24} style={{ marginBottom: '8px' }} />
|
||||||
|
<div style={{ fontWeight: 600 }}>Error loading trips</div>
|
||||||
|
<div>{error}</div>
|
||||||
|
</div>
|
||||||
|
) : filteredVehicles.length === 0 ? (
|
||||||
|
<div style={{ padding: '60px', textAlign: 'center', color: '#64748B', background: '#FFFFFF', borderRadius: '16px', border: '1px solid #CBD5E1' }}>
|
||||||
|
<MapPin size={48} style={{ opacity: 0.2, margin: '0 auto 16px' }} />
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, color: '#475569' }}>No trips found</div>
|
||||||
|
<div style={{ fontSize: '0.875rem' }}>Adjust your filters to see more results</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '16px' }}>
|
||||||
|
{pageData.map(v => {
|
||||||
|
const sc = getStatusColor(v.status);
|
||||||
|
const shift = v.activeShift;
|
||||||
|
const roster = v.activeRoster;
|
||||||
|
|
||||||
|
const driverName = shift?.driver?.user?.name || roster?.driver?.user?.name || 'Unassigned';
|
||||||
|
const emtName = shift?.staff?.user?.name || roster?.staff?.user?.name || 'Unassigned';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={v.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
style={{
|
||||||
|
background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: '16px', padding: '20px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '16px', borderLeft: `4px solid ${sc.color}`,
|
||||||
|
boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: '#F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#475569' }}>
|
||||||
|
<Truck size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800, color: '#0F172A' }}>{v.registration_number}</h3>
|
||||||
|
<span style={{ fontSize: '0.65rem', padding: '4px 8px', borderRadius: '6px', background: '#F1F5F9', color: '#475569', fontWeight: 700 }}>{v.vehicle_type} UNIT</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748B', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<MapPin size={14} />
|
||||||
|
{v.gps_lat && v.gps_lat !== "0.0000000" ? `${v.gps_lat}, ${v.gps_lon}` : 'Location Unavailable'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '8px', fontSize: '0.75rem', fontWeight: 800, background: sc.bg, color: sc.color }}>
|
||||||
|
{v.status || 'UNKNOWN'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: '1px', background: '#F1F5F9' }} />
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', marginBottom: '6px' }}>Driver / Pilot</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#0F172A', fontWeight: 600 }}>
|
||||||
|
<User size={16} color="#64748B" /> {driverName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', marginBottom: '6px' }}>Paramedic / EMT</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#0F172A', fontWeight: 600 }}>
|
||||||
|
<User size={16} color="#64748B" /> {emtName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shift && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', marginBottom: '6px' }}>Shift Details</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#0F172A' }}>
|
||||||
|
<Clock size={16} color="#64748B" />
|
||||||
|
Started: {new Date(shift.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
.spin { animation: spin 1s linear infinite; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
944
src/pages/fleet/FleetWarehouseStock.tsx
Normal file
944
src/pages/fleet/FleetWarehouseStock.tsx
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Database, Search, Activity, AlertTriangle, ArrowLeft, X, ChevronLeft, ChevronRight, 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';
|
||||||
|
|
||||||
|
export const FleetWarehouseStock: React.FC = () => {
|
||||||
|
const [, setSearchParams] = useSearchParams();
|
||||||
|
const [stockList, setStockList] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
// Master Inventory for dropdowns
|
||||||
|
const [inventory, setInventory] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Bulk Restock states
|
||||||
|
interface BulkRestockRow {
|
||||||
|
itemId: string;
|
||||||
|
quantity: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
const [showBulkRestockModal, setShowBulkRestockModal] = useState<boolean>(false);
|
||||||
|
const [bulkRestockRows, setBulkRestockRows] = useState<BulkRestockRow[]>([
|
||||||
|
{ itemId: '', quantity: 100, reason: 'Shipment from HealthCare Distributors' }
|
||||||
|
]);
|
||||||
|
const [bulkRestockIsSubmitting, setBulkRestockIsSubmitting] = useState<boolean>(false);
|
||||||
|
const [bulkRestockError, setBulkRestockError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Assign to Vehicle states
|
||||||
|
interface AssignVehicleRow {
|
||||||
|
itemId: string;
|
||||||
|
quantity: number;
|
||||||
|
batch_number: string;
|
||||||
|
expiry_date: string;
|
||||||
|
}
|
||||||
|
const [showAssignVehicleModal, setShowAssignVehicleModal] = useState<boolean>(false);
|
||||||
|
const [assignVehicleId, setAssignVehicleId] = useState<string>('');
|
||||||
|
const [assignSupplierName, setAssignSupplierName] = useState<string>('Central Warehouse');
|
||||||
|
const [assignReason, setAssignReason] = useState<string>('Ambulance Initial Stocking');
|
||||||
|
const [assignVehicleRows, setAssignVehicleRows] = useState<AssignVehicleRow[]>([
|
||||||
|
{ itemId: '', quantity: 100, batch_number: '', expiry_date: '' }
|
||||||
|
]);
|
||||||
|
const [assignVehicleIsSubmitting, setAssignVehicleIsSubmitting] = useState<boolean>(false);
|
||||||
|
const [assignVehicleError, setAssignVehicleError] = useState<string | null>(null);
|
||||||
|
const [vehiclesList, setVehiclesList] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Pending restock requests states
|
||||||
|
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
|
||||||
|
const [loadingRequests, setLoadingRequests] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingRequests(true);
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
if (!token) return;
|
||||||
|
const res = await fleetApi.getPendingRestockRequests(token);
|
||||||
|
const data = res?.data?.data || res?.data || [];
|
||||||
|
setPendingRequests(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch pending requests:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingRequests(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStock = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const response = await fleetApi.getWarehouseStock(token);
|
||||||
|
const data = response?.data?.data?.data || response?.data?.data || response?.data || [];
|
||||||
|
setStockList(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch warehouse stock:', err);
|
||||||
|
setError(err?.message || 'Failed to fetch warehouse stock data.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStock();
|
||||||
|
fetchRequests();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInventory = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const response = await fleetApi.getInventoryMaster(token);
|
||||||
|
const data = response?.data?.data || response?.data || [];
|
||||||
|
setInventory(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch inventory master:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchInventory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVehicles = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const userStr = localStorage.getItem('teleems_user');
|
||||||
|
let orgId = '';
|
||||||
|
if (userStr) {
|
||||||
|
const u = JSON.parse(userStr);
|
||||||
|
orgId = u.organisationId || '';
|
||||||
|
}
|
||||||
|
const res = await fleetApi.getVehicles(token, orgId);
|
||||||
|
const data = res?.data?.data || res?.data || [];
|
||||||
|
setVehiclesList(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch vehicles:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchVehicles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmitBulkRestock = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBulkRestockError(null);
|
||||||
|
setBulkRestockIsSubmitting(true);
|
||||||
|
|
||||||
|
if (bulkRestockRows.length === 0) {
|
||||||
|
setBulkRestockError('Please add at least one item to restock.');
|
||||||
|
setBulkRestockIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidRow = bulkRestockRows.find(row => !row.itemId || row.quantity <= 0);
|
||||||
|
if (invalidRow) {
|
||||||
|
setBulkRestockError('Please ensure all rows have an item selected and a positive quantity.');
|
||||||
|
setBulkRestockIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const payload = bulkRestockRows.map(row => ({
|
||||||
|
itemId: row.itemId,
|
||||||
|
quantity: Number(row.quantity),
|
||||||
|
reason: row.reason || 'Bulk Shipment'
|
||||||
|
}));
|
||||||
|
|
||||||
|
await fleetApi.restockInventory(payload, token);
|
||||||
|
alert(`Successfully processed bulk restock for ${bulkRestockRows.length} items!`);
|
||||||
|
setShowBulkRestockModal(false);
|
||||||
|
fetchStock();
|
||||||
|
fetchRequests();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed bulk restock:', err);
|
||||||
|
setBulkRestockError(err?.message || 'Failed to submit bulk restock. Check item compatibility or try again.');
|
||||||
|
} finally {
|
||||||
|
setBulkRestockIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitAssignVehicle = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setAssignVehicleError(null);
|
||||||
|
setAssignVehicleIsSubmitting(true);
|
||||||
|
|
||||||
|
if (!assignVehicleId) {
|
||||||
|
setAssignVehicleError('Please select a vehicle.');
|
||||||
|
setAssignVehicleIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignVehicleRows.length === 0) {
|
||||||
|
setAssignVehicleError('Please add at least one item to assign.');
|
||||||
|
setAssignVehicleIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidRow = assignVehicleRows.find(row => !row.itemId || row.quantity <= 0);
|
||||||
|
if (invalidRow) {
|
||||||
|
setAssignVehicleError('Please ensure all rows have an item selected and a positive quantity.');
|
||||||
|
setAssignVehicleIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const payload = {
|
||||||
|
supplier_name: assignSupplierName,
|
||||||
|
reason: assignReason,
|
||||||
|
items: assignVehicleRows.map(row => ({
|
||||||
|
itemId: row.itemId,
|
||||||
|
quantity: Number(row.quantity),
|
||||||
|
batch_number: row.batch_number || 'N/A',
|
||||||
|
expiry_date: row.expiry_date || new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split('T')[0]
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
await fleetApi.assignToVehicle(assignVehicleId, payload, token);
|
||||||
|
alert(`Successfully assigned inventory to vehicle!`);
|
||||||
|
setShowAssignVehicleModal(false);
|
||||||
|
fetchStock();
|
||||||
|
fetchRequests();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to assign to vehicle:', err);
|
||||||
|
setAssignVehicleError(err?.message || 'Failed to submit assignment. Check item details or try again.');
|
||||||
|
} finally {
|
||||||
|
setAssignVehicleIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 totalPages = Math.ceil(filteredStock.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredStock.slice(indexOfFirstItem, indexOfLastItem);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in duration-500">
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchParams({ tab: 'inventory' })}
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8', borderRadius: '12px', width: '52px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.2s', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.1)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
|
||||||
|
title="Back to Inventory"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isSearchFocused ? '1px solid #06B6D4' : '1px solid #CBD5E1',
|
||||||
|
boxShadow: isSearchFocused ? '0 0 0 3px rgba(6, 182, 212, 0.15)' : '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
width: '380px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<Search size={16} color={isSearchFocused ? "#06B6D4" : "#64748B"} style={{ transition: 'color 0.2s' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search warehouse stock by name or category..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
className="stations-search-input"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const initialItemId = inventory.length > 0 ? inventory[0].id : '';
|
||||||
|
setBulkRestockRows([
|
||||||
|
{ itemId: initialItemId, quantity: 100, reason: 'Shipment from HealthCare Distributors' }
|
||||||
|
]);
|
||||||
|
setBulkRestockError(null);
|
||||||
|
setShowBulkRestockModal(true);
|
||||||
|
}}
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
background: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
color: '#3B82F6',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
marginLeft: 'auto'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShoppingCart size={16} /> BULK RESTOCK
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const initialItemId = inventory.length > 0 ? inventory[0].id : '';
|
||||||
|
setAssignVehicleRows([
|
||||||
|
{ itemId: initialItemId, quantity: 100, batch_number: '', expiry_date: '' }
|
||||||
|
]);
|
||||||
|
setAssignVehicleError(null);
|
||||||
|
setShowAssignVehicleModal(true);
|
||||||
|
}}
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
background: 'rgba(245, 158, 11, 0.1)',
|
||||||
|
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||||
|
color: '#F59E0B',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(245, 158, 11, 0.2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(245, 158, 11, 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Truck size={16} /> ASSIGN TO VEHICLE
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchParams({ tab: 'pending-requests' })}
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
background: 'rgba(6, 182, 212, 0.1)',
|
||||||
|
border: '1px solid rgba(6, 182, 212, 0.3)',
|
||||||
|
color: '#06B6D4',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardList size={16} /> STOCK REQUESTS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<Card title="Warehouse Inventory Catalog">
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '100px', gap: '16px', color: 'var(--text-secondary)' }}>
|
||||||
|
<Activity size={32} className="spin" style={{ color: 'var(--accent-cyan)' }} />
|
||||||
|
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>SYNCHRONIZING WAREHOUSE DATA...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '80px 24px', textAlign: 'center', color: '#EF4444' }}>
|
||||||
|
<AlertTriangle size={48} style={{ marginBottom: '16px', opacity: 0.8 }} />
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, marginBottom: '8px' }}>Warehouse Connection Failed</h3>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: '#94A3B8', maxWidth: '360px', margin: '0 auto' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
) : filteredStock.length === 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '100px 24px', color: '#64748B' }}>
|
||||||
|
<Database size={48} style={{ opacity: 0.2, marginBottom: '16px' }} />
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 700, color: '#94A3B8', marginBottom: '4px' }}>Warehouse is Empty</h3>
|
||||||
|
<p style={{ fontSize: '0.8125rem' }}>No stock data found or search returned no results.</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(255,255,255,0.1)' }}>
|
||||||
|
<th style={{ padding: '16px 12px' }}>Supply Item</th>
|
||||||
|
<th style={{ padding: '16px 12px' }}>Category</th>
|
||||||
|
<th style={{ padding: '16px 12px' }}>Quantity Available</th>
|
||||||
|
<th style={{ padding: '16px 12px' }}>Reorder Point</th>
|
||||||
|
<th style={{ padding: '16px 12px' }}>Last Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentItems.map((item) => (
|
||||||
|
<tr key={item.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="hover-glow">
|
||||||
|
<td style={{ padding: '16px 12px' }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.875rem' }}>
|
||||||
|
{item.item_master?.name || 'Unknown Item'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px 12px' }}>
|
||||||
|
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#06B6D4', background: 'rgba(6, 182, 212, 0.1)', padding: '4px 8px', borderRadius: '6px' }}>
|
||||||
|
{item.item_master?.category || 'GENERAL'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px 12px' }}>
|
||||||
|
<div style={{ fontWeight: 900, fontSize: '1.1rem', color: '#10B981' }}>
|
||||||
|
{item.quantity}
|
||||||
|
<span style={{ fontSize: '0.7rem', color: '#64748B', fontWeight: 500, marginLeft: '6px' }}>Units</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px 12px' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#94A3B8', fontWeight: 600 }}>
|
||||||
|
{item.item_master?.min_stock_threshold || 0}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px 12px' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||||
|
{new Date(item.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Bulk Restock Modal */}
|
||||||
|
{showBulkRestockModal && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(15, 23, 42, 0.4)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1500,
|
||||||
|
animation: 'fadeIn 0.25s ease-out'
|
||||||
|
}}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '820px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '30px',
|
||||||
|
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.15)',
|
||||||
|
fontFamily: "'Inter', sans-serif",
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', borderBottom: '1px solid #F1F5F9', paddingBottom: '16px', flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '1.25rem', fontWeight: 900, color: '#0F172A', margin: 0, letterSpacing: '-0.5px' }}>Bulk Restock Intake Ledger</h3>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#0284C7', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '1px' }}>Warehouse bulk stock ingestion</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkRestockModal(false)}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: '#64748B', cursor: 'pointer', fontSize: '1.1rem' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#0F172A'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmitBulkRestock} style={{ display: 'flex', flexDirection: 'column', gap: '20px', overflow: 'hidden', flex: 1 }}>
|
||||||
|
{bulkRestockError && (
|
||||||
|
<div style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)', color: '#EF4444', padding: '12px 16px', borderRadius: '10px', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<span>{bulkRestockError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rows List Container */}
|
||||||
|
<div style={{ overflowY: 'auto', paddingRight: '4px', display: 'flex', flexDirection: 'column', gap: '12px', flex: 1, minHeight: '150px' }}>
|
||||||
|
{bulkRestockRows.map((row, index) => {
|
||||||
|
const matchedItem = inventory.find(item => item.id === row.itemId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1.5fr 1fr 1.5fr auto',
|
||||||
|
gap: '12px',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: '#F8FAFC',
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Item Select */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<label style={{ fontSize: '0.65rem', fontWeight: 800, color: '#64748B', textTransform: 'uppercase' }}>Select Supply Item</label>
|
||||||
|
<select
|
||||||
|
value={row.itemId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...bulkRestockRows];
|
||||||
|
newRows[index].itemId = e.target.value;
|
||||||
|
setBulkRestockRows(newRows);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
outline: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">-- Choose Item --</option>
|
||||||
|
{inventory.map(item => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.category})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity Input */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<label style={{ fontSize: '0.65rem', fontWeight: 800, color: '#64748B', textTransform: 'uppercase' }}>
|
||||||
|
Quantity {matchedItem ? `(${matchedItem.unit_of_measure || matchedItem.unit || 'Units'})` : ''}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={row.quantity}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...bulkRestockRows];
|
||||||
|
newRows[index].quantity = Number(e.target.value);
|
||||||
|
setBulkRestockRows(newRows);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
outline: 'none',
|
||||||
|
fontWeight: 700
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reason Select/Input */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<label style={{ fontSize: '0.65rem', fontWeight: 800, color: '#64748B', textTransform: 'uppercase' }}>Reason / Supplier</label>
|
||||||
|
<select
|
||||||
|
value={row.reason}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...bulkRestockRows];
|
||||||
|
newRows[index].reason = e.target.value;
|
||||||
|
setBulkRestockRows(newRows);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
outline: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="Shipment from HealthCare Distributors">Shipment from HealthCare Distributors</option>
|
||||||
|
<option value="Shipment from MediStore">Shipment from MediStore</option>
|
||||||
|
<option value="Routine Stock Replenishment">Routine Stock Replenishment</option>
|
||||||
|
<option value="Emergency Intake">Emergency Intake</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove Action */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newRows = bulkRestockRows.filter((_, rIdx) => rIdx !== index);
|
||||||
|
setBulkRestockRows(newRows);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: 'none',
|
||||||
|
color: '#EF4444',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'end',
|
||||||
|
marginBottom: '2px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(239, 68, 68, 0.2)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'}
|
||||||
|
title="Remove row"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add Row Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const firstId = inventory.length > 0 ? inventory[0].id : '';
|
||||||
|
setBulkRestockRows([
|
||||||
|
...bulkRestockRows,
|
||||||
|
{ itemId: firstId, quantity: 100, reason: 'Shipment from HealthCare Distributors' }
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
background: 'rgba(2, 132, 199, 0.05)',
|
||||||
|
border: '1px dashed rgba(2, 132, 199, 0.3)',
|
||||||
|
color: '#0284C7',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(2, 132, 199, 0.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(2, 132, 199, 0.05)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusCircle size={16} /> ADD ANOTHER ITEM ROW
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Buttons */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', borderTop: '1px solid #F1F5F9', paddingTop: '20px', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBulkRestockModal(false)}
|
||||||
|
className="btn-ghost"
|
||||||
|
style={{ padding: '10px 20px', fontSize: '0.8rem', color: '#475569', background: 'transparent', border: 'none', cursor: 'pointer', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={bulkRestockIsSubmitting}
|
||||||
|
style={{ padding: '10px 24px', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '8px', background: '#22C55E', color: '#FFFFFF', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{bulkRestockIsSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Activity size={14} className="spin" /> PROCESSING...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'SUBMIT BULK INGESTION'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assign to Vehicle Modal */}
|
||||||
|
{showAssignVehicleModal && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(15, 23, 42, 0.4)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) setShowAssignVehicleModal(false); }}>
|
||||||
|
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} style={{ background: '#FFFFFF', borderRadius: '16px', width: '900px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 40px rgba(15, 23, 42, 0.2)' }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '20px 24px', borderBottom: '1px solid #F1F5F9', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#F8FAFC' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ background: 'rgba(245, 158, 11, 0.1)', padding: '10px', borderRadius: '12px', color: '#F59E0B' }}>
|
||||||
|
<Truck size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800, color: '#0F172A' }}>Assign to Vehicle</h2>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '0.8rem', color: '#64748B' }}>Transfer inventory stock to an active fleet vehicle</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowAssignVehicleModal(false)} style={{ background: '#FFFFFF', border: '1px solid #E2E8F0', padding: '6px', borderRadius: '8px', cursor: 'pointer', color: '#64748B' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmitAssignVehicle} style={{ flex: 1, overflowY: 'auto', padding: '24px', display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
|
{assignVehicleError && (
|
||||||
|
<div style={{ padding: '12px 16px', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)', borderRadius: '8px', color: '#EF4444', fontSize: '0.85rem', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<AlertTriangle size={16} /> {assignVehicleError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Configuration Details */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px', background: '#F8FAFC', padding: '16px', borderRadius: '12px', border: '1px solid #E2E8F0' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 700, color: '#475569', marginBottom: '6px' }}>Select Target Vehicle *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={assignVehicleId}
|
||||||
|
onChange={(e) => setAssignVehicleId(e.target.value)}
|
||||||
|
style={{ width: '100%', background: '#FFFFFF', border: '1px solid #D1D5DB', borderRadius: '8px', padding: '8px 12px', color: '#0F172A', fontSize: '0.8rem', outline: 'none' }}
|
||||||
|
>
|
||||||
|
<option value="" disabled>-- Select Vehicle --</option>
|
||||||
|
{vehiclesList.map(v => (
|
||||||
|
<option key={v.id} value={v.id}>{v.registration_number} {v.brand ? `(${v.brand})` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 700, color: '#475569', marginBottom: '6px' }}>Supplier / Source</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={assignSupplierName}
|
||||||
|
onChange={(e) => setAssignSupplierName(e.target.value)}
|
||||||
|
style={{ width: '100%', background: '#FFFFFF', border: '1px solid #D1D5DB', borderRadius: '8px', padding: '8px 12px', color: '#0F172A', fontSize: '0.8rem', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 700, color: '#475569', marginBottom: '6px' }}>Assignment Reason</label>
|
||||||
|
<select
|
||||||
|
value={assignReason}
|
||||||
|
onChange={(e) => setAssignReason(e.target.value)}
|
||||||
|
style={{ width: '100%', background: '#FFFFFF', border: '1px solid #D1D5DB', borderRadius: '8px', padding: '8px 12px', color: '#0F172A', fontSize: '0.8rem', outline: 'none' }}
|
||||||
|
>
|
||||||
|
<option value="Ambulance Initial Stocking">Ambulance Initial Stocking</option>
|
||||||
|
<option value="Routine Restock">Routine Restock</option>
|
||||||
|
<option value="Emergency Restock">Emergency Restock</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 700, color: '#0F172A', borderBottom: '1px solid #E2E8F0', paddingBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Package size={16} color="#0284C7" /> Items to Assign
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{assignVehicleRows.map((row, index) => (
|
||||||
|
<div key={index} style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr auto', gap: '12px', alignItems: 'center', background: '#FFFFFF', padding: '12px', borderRadius: '12px', border: '1px solid #E2E8F0' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.65rem', fontWeight: 700, color: '#64748B', marginBottom: '4px', textTransform: 'uppercase' }}>Select Item</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={row.itemId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...assignVehicleRows];
|
||||||
|
newRows[index].itemId = e.target.value;
|
||||||
|
setAssignVehicleRows(newRows);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', background: '#F8FAFC', border: '1px solid #D1D5DB', borderRadius: '6px', padding: '8px', color: '#0F172A', fontSize: '0.8rem', outline: 'none' }}
|
||||||
|
>
|
||||||
|
<option value="" disabled>-- Select item --</option>
|
||||||
|
{inventory.map(invItem => (
|
||||||
|
<option key={invItem.id} value={invItem.id}>{invItem.name} ({invItem.unit_of_measure || invItem.unit || 'Units'})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.65rem', fontWeight: 700, color: '#64748B', marginBottom: '4px', textTransform: 'uppercase' }}>Quantity</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
value={row.quantity}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...assignVehicleRows];
|
||||||
|
newRows[index].quantity = Number(e.target.value);
|
||||||
|
setAssignVehicleRows(newRows);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', background: '#F8FAFC', border: '1px solid #D1D5DB', borderRadius: '6px', padding: '8px', color: '#0F172A', fontSize: '0.8rem', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.65rem', fontWeight: 700, color: '#64748B', marginBottom: '4px', textTransform: 'uppercase' }}>Batch No</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. B-2024"
|
||||||
|
value={row.batch_number}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...assignVehicleRows];
|
||||||
|
newRows[index].batch_number = e.target.value;
|
||||||
|
setAssignVehicleRows(newRows);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', background: '#F8FAFC', border: '1px solid #D1D5DB', borderRadius: '6px', padding: '8px', color: '#0F172A', fontSize: '0.8rem', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.65rem', fontWeight: 700, color: '#64748B', marginBottom: '4px', textTransform: 'uppercase' }}>Expiry Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={row.expiry_date}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...assignVehicleRows];
|
||||||
|
newRows[index].expiry_date = e.target.value;
|
||||||
|
setAssignVehicleRows(newRows);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', background: '#F8FAFC', border: '1px solid #D1D5DB', borderRadius: '6px', padding: '8px', color: '#0F172A', fontSize: '0.8rem', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAssignVehicleRows(assignVehicleRows.filter((_, rIdx) => rIdx !== index))}
|
||||||
|
style={{ background: 'rgba(239, 68, 68, 0.1)', border: 'none', color: '#EF4444', padding: '8px', borderRadius: '8px', cursor: 'pointer', alignSelf: 'end', marginBottom: '2px' }}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const firstId = inventory.length > 0 ? inventory[0].id : '';
|
||||||
|
setAssignVehicleRows([...assignVehicleRows, { itemId: firstId, quantity: 100, batch_number: '', expiry_date: '' }]);
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', background: 'rgba(245, 158, 11, 0.05)', border: '1px dashed rgba(245, 158, 11, 0.3)', color: '#F59E0B', borderRadius: '12px', padding: '12px', fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<PlusCircle size={16} /> ADD ANOTHER ITEM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Buttons */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', borderTop: '1px solid #F1F5F9', paddingTop: '20px', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAssignVehicleModal(false)}
|
||||||
|
style={{ padding: '10px 20px', fontSize: '0.8rem', color: '#475569', background: 'transparent', border: 'none', cursor: 'pointer', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={assignVehicleIsSubmitting}
|
||||||
|
style={{ padding: '10px 24px', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '8px', background: '#F59E0B', color: '#FFFFFF', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{assignVehicleIsSubmitting ? <><Activity size={14} className="spin" /> PROCESSING...</> : 'CONFIRM ASSIGNMENT'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
.spin { animation: spin 1s linear infinite; }
|
||||||
|
.hover-glow:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
749
src/pages/fleet/LiveDashboard.tsx
Normal file
749
src/pages/fleet/LiveDashboard.tsx
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
/// <reference types="@types/google.maps" />
|
||||||
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import {
|
||||||
|
Truck, Search, MapPin, Radio, Activity, X, Check,
|
||||||
|
Loader2, Compass, Play, RefreshCw
|
||||||
|
} from 'lucide-react';
|
||||||
|
// import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
interface Vehicle {
|
||||||
|
id: string;
|
||||||
|
registration_number: string;
|
||||||
|
chassis_number?: string | null;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
vehicle_type: string; // ALS, BLS, etc.
|
||||||
|
status: string; // BUSY, AVAILABLE, OFFLINE
|
||||||
|
gps_lat: string | number;
|
||||||
|
gps_lon: string | number;
|
||||||
|
activeShift?: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
staff?: {
|
||||||
|
user?: {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Google Maps Script Helper ---
|
||||||
|
let mapsScriptLoaded = false;
|
||||||
|
let mapsScriptLoadingPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const loadGoogleMapsScript = (apiKey: string): Promise<void> => {
|
||||||
|
if (mapsScriptLoaded) return Promise.resolve();
|
||||||
|
if (mapsScriptLoadingPromise) return mapsScriptLoadingPromise;
|
||||||
|
|
||||||
|
mapsScriptLoadingPromise = new Promise((resolve, reject) => {
|
||||||
|
if ((window as any).google && (window as any).google.maps) {
|
||||||
|
mapsScriptLoaded = true;
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set dynamic callback name
|
||||||
|
const callbackName = 'initGoogleMapsCallback';
|
||||||
|
(window as any)[callbackName] = () => {
|
||||||
|
mapsScriptLoaded = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&loading=async`;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onerror = (err) => {
|
||||||
|
console.error('Google Maps Script load failed:', err);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapsScriptLoadingPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map custom aesthetic style (sleek dark/light theme balancing high-contrast premium feel)
|
||||||
|
const mapStyles = [
|
||||||
|
{
|
||||||
|
"elementType": "geometry",
|
||||||
|
"stylers": [{ "color": "#f5f5f5" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"elementType": "labels.icon",
|
||||||
|
"stylers": [{ "visibility": "off" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"elementType": "labels.text.fill",
|
||||||
|
"stylers": [{ "color": "#616161" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"elementType": "labels.text.stroke",
|
||||||
|
"stylers": [{ "color": "#f5f5f5" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "administrative.land_parcel",
|
||||||
|
"elementType": "labels.text.fill",
|
||||||
|
"stylers": [{ "color": "#bdbdbd" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "poi",
|
||||||
|
"elementType": "geometry",
|
||||||
|
"stylers": [{ "color": "#eeeeee" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "poi",
|
||||||
|
"elementType": "labels.text.fill",
|
||||||
|
"stylers": [{ "color": "#757575" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "road",
|
||||||
|
"elementType": "geometry",
|
||||||
|
"stylers": [{ "color": "#ffffff" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "road.arterial",
|
||||||
|
"elementType": "labels.text.fill",
|
||||||
|
"stylers": [{ "color": "#757575" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "road.highway",
|
||||||
|
"elementType": "geometry",
|
||||||
|
"stylers": [{ "color": "#dadada" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "road.highway",
|
||||||
|
"elementType": "labels.text.fill",
|
||||||
|
"stylers": [{ "color": "#616161" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "road.local",
|
||||||
|
"elementType": "labels.text.fill",
|
||||||
|
"stylers": [{ "color": "#9e9e9e" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "transit.line",
|
||||||
|
"elementType": "geometry",
|
||||||
|
"stylers": [{ "color": "#e5e5e5" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "transit.station",
|
||||||
|
"elementType": "geometry",
|
||||||
|
"stylers": [{ "color": "#eeeeee" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "water",
|
||||||
|
"elementType": "geometry",
|
||||||
|
"stylers": [{ "color": "#c9c9c9" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"featureType": "water",
|
||||||
|
"elementType": "labels.text.fill",
|
||||||
|
"stylers": [{ "color": "#9e9e9e" }]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LiveDashboard: React.FC = () => {
|
||||||
|
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [socketStatus, setSocketStatus] = useState<'disconnected' | 'connecting' | 'connected'>('connecting');
|
||||||
|
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||||||
|
|
||||||
|
// References to prevent stale closures inside socket events
|
||||||
|
const vehiclesRef = useRef<Vehicle[]>([]);
|
||||||
|
const selectedVehicleRef = useRef<Vehicle | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vehiclesRef.current = vehicles;
|
||||||
|
}, [vehicles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedVehicleRef.current = selectedVehicle;
|
||||||
|
}, [selectedVehicle]);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const [totalVehicles, setTotalVehicles] = useState(0);
|
||||||
|
const [busyCount, setBusyCount] = useState(0);
|
||||||
|
const [availableCount, setAvailableCount] = useState(0);
|
||||||
|
|
||||||
|
// References
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<google.maps.Map | null>(null);
|
||||||
|
const markersRef = useRef<Record<string, google.maps.Marker>>({});
|
||||||
|
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
const API_KEY = 'AIzaSyA9fe3iFd2XxDDUQNGJLs6Tp5iRewpHdhs';
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
|
||||||
|
// Get status details
|
||||||
|
const getStatusCfg = (status: string) => {
|
||||||
|
switch ((status || '').toUpperCase()) {
|
||||||
|
case 'BUSY':
|
||||||
|
return { label: 'Busy', color: '#F97316', bg: '#FFF7ED', border: '#FFEDD5' };
|
||||||
|
case 'AVAILABLE':
|
||||||
|
return { label: 'Available', color: '#10B981', bg: '#ECFDF5', border: '#D1FAE5' };
|
||||||
|
default:
|
||||||
|
return { label: 'Offline', color: '#64748B', bg: '#F8FAFC', border: '#F1F5F9' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch initial list of vehicles
|
||||||
|
const fetchVehicles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/vehicles', {
|
||||||
|
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);
|
||||||
|
setTotalVehicles(list.length);
|
||||||
|
setBusyCount(list.filter(v => v.status === 'BUSY').length);
|
||||||
|
setAvailableCount(list.filter(v => v.status === 'AVAILABLE').length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching vehicles:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// Center map on specific marker
|
||||||
|
const centerOnVehicle = (vehicle: Vehicle) => {
|
||||||
|
const lat = parseFloat(vehicle.gps_lat as string);
|
||||||
|
const lon = parseFloat(vehicle.gps_lon as string);
|
||||||
|
|
||||||
|
if (isNaN(lat) || isNaN(lon) || (lat === 0 && lon === 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedVehicle(vehicle);
|
||||||
|
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.setZoom(15);
|
||||||
|
mapInstanceRef.current.panTo({ lat, lng: lon });
|
||||||
|
|
||||||
|
// Open infowindow
|
||||||
|
const marker = markersRef.current[vehicle.id];
|
||||||
|
if (marker && infoWindowRef.current) {
|
||||||
|
const sc = getStatusCfg(vehicle.status);
|
||||||
|
const driverName = vehicle.activeShift?.staff?.user?.name || 'No Pilot Assigned';
|
||||||
|
const content = `
|
||||||
|
<div style="padding: 10px; font-family: 'Inter', sans-serif; min-width: 180px; color: #0F172A;">
|
||||||
|
<div style="font-size: 0.65rem; font-weight: 800; color: #64748B; text-transform: uppercase; margin-bottom: 3px;">${vehicle.vehicle_type} AMBULANCE</div>
|
||||||
|
<div style="font-size: 0.95rem; font-weight: 800; color: #0F172A; margin-bottom: 6px;">${vehicle.registration_number}</div>
|
||||||
|
<div style="font-size: 0.75rem; color: #475569; margin-bottom: 8px;">${vehicle.brand || ''} ${vehicle.model || ''}</div>
|
||||||
|
<div style="display: flex; gap: 6px; align-items: center; margin-top: 4px;">
|
||||||
|
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background-color: ${sc.color};"></span>
|
||||||
|
<span style="font-size: 0.72rem; font-weight: 700; color: ${sc.color};">${sc.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.7rem; color: #64748B; margin-top: 8px; border-top: 1px solid #E2E8F0; padding-top: 6px;">
|
||||||
|
Pilot: <strong>${driverName}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
infoWindowRef.current.setContent(content);
|
||||||
|
infoWindowRef.current.open(mapInstanceRef.current, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup maps and socket connections
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVehicles();
|
||||||
|
}, [fetchVehicles]);
|
||||||
|
|
||||||
|
// Google Maps setup once vehicles list is ready and element exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || vehicles.length === 0 || !mapContainerRef.current) return;
|
||||||
|
|
||||||
|
loadGoogleMapsScript(API_KEY)
|
||||||
|
.then(() => {
|
||||||
|
// Find center based on active coords
|
||||||
|
const validCoords = vehicles
|
||||||
|
.map(v => ({ lat: parseFloat(v.gps_lat as string), lng: parseFloat(v.gps_lon as string) }))
|
||||||
|
.filter(c => !isNaN(c.lat) && !isNaN(c.lng) && c.lat !== 0 && c.lng !== 0);
|
||||||
|
|
||||||
|
const defaultCenter = validCoords.length > 0
|
||||||
|
? validCoords[0]
|
||||||
|
: { lat: 18.5204, lng: 73.8567 }; // Default Pune center
|
||||||
|
|
||||||
|
const mapOptions: google.maps.MapOptions = {
|
||||||
|
center: defaultCenter,
|
||||||
|
zoom: 12,
|
||||||
|
styles: mapStyles,
|
||||||
|
mapTypeControl: false,
|
||||||
|
streetViewControl: false,
|
||||||
|
fullscreenControl: false,
|
||||||
|
zoomControl: true,
|
||||||
|
zoomControlOptions: {
|
||||||
|
position: google.maps.ControlPosition.RIGHT_CENTER
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = new google.maps.Map(mapContainerRef.current!, mapOptions);
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
infoWindowRef.current = new google.maps.InfoWindow();
|
||||||
|
|
||||||
|
// Create Markers
|
||||||
|
vehicles.forEach(v => {
|
||||||
|
const lat = parseFloat(v.gps_lat as string);
|
||||||
|
const lon = parseFloat(v.gps_lon as string);
|
||||||
|
|
||||||
|
if (isNaN(lat) || isNaN(lon) || (lat === 0 && lon === 0)) return;
|
||||||
|
|
||||||
|
const sc = getStatusCfg(v.status);
|
||||||
|
|
||||||
|
// High quality SVG markers for vehicles
|
||||||
|
const svgIcon = {
|
||||||
|
path: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z',
|
||||||
|
fillColor: sc.color,
|
||||||
|
fillOpacity: 1,
|
||||||
|
strokeColor: '#FFFFFF',
|
||||||
|
strokeWeight: 2,
|
||||||
|
scale: 1.8,
|
||||||
|
anchor: new google.maps.Point(12, 24)
|
||||||
|
};
|
||||||
|
|
||||||
|
const marker = new google.maps.Marker({
|
||||||
|
position: { lat, lng: lon },
|
||||||
|
map,
|
||||||
|
title: v.registration_number,
|
||||||
|
icon: svgIcon,
|
||||||
|
animation: google.maps.Animation.DROP
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.addListener('click', () => {
|
||||||
|
centerOnVehicle(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
markersRef.current[v.id] = marker;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Google Maps Load Error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup markers
|
||||||
|
Object.values(markersRef.current).forEach(m => m.setMap(null));
|
||||||
|
markersRef.current = {};
|
||||||
|
};
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
// Real-time socket client connection
|
||||||
|
useEffect(() => {
|
||||||
|
setSocketStatus('connecting');
|
||||||
|
const socket = io('https://teleems-api-gateway.onrender.com', {
|
||||||
|
transports: ['websocket'],
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('Socket.io connected successfully!');
|
||||||
|
setSocketStatus('connected');
|
||||||
|
|
||||||
|
// Emit subscription event
|
||||||
|
socket.emit('subscribe_fleet');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('Socket.io disconnected!');
|
||||||
|
setSocketStatus('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real-time updates listener
|
||||||
|
socket.on('fleet:location_updated', (data: any) => {
|
||||||
|
console.log('Real-time location received:', data);
|
||||||
|
|
||||||
|
const vId = data.vehicle_id || data.id;
|
||||||
|
const regNo = data.registration_number;
|
||||||
|
const lat = parseFloat(data.lat || data.gps_lat);
|
||||||
|
const lon = parseFloat(data.lon || data.gps_lon);
|
||||||
|
|
||||||
|
if (!vId || isNaN(lat) || isNaN(lon)) return;
|
||||||
|
|
||||||
|
// Update vehicle state coordinates in vehicle array dynamically
|
||||||
|
setVehicles(prev => {
|
||||||
|
const index = prev.findIndex(v => v.id === vId);
|
||||||
|
if (index === -1) return prev;
|
||||||
|
const copy = [...prev];
|
||||||
|
copy[index] = {
|
||||||
|
...copy[index],
|
||||||
|
gps_lat: lat,
|
||||||
|
gps_lon: lon
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update marker coordinates dynamically on map
|
||||||
|
let marker = markersRef.current[vId];
|
||||||
|
if (marker) {
|
||||||
|
const newPos = new google.maps.LatLng(lat, lon);
|
||||||
|
marker.setPosition(newPos);
|
||||||
|
|
||||||
|
// Soft focus visual if selected
|
||||||
|
if (selectedVehicleRef.current?.id === vId) {
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.panTo(newPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (mapInstanceRef.current) {
|
||||||
|
// Create marker dynamically if it didn't exist originally due to missing coordinates
|
||||||
|
const vehicleInfo = vehiclesRef.current.find(v => v.id === vId);
|
||||||
|
const sc = getStatusCfg(vehicleInfo?.status || 'AVAILABLE');
|
||||||
|
const svgIcon = {
|
||||||
|
path: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z',
|
||||||
|
fillColor: sc.color,
|
||||||
|
fillOpacity: 1,
|
||||||
|
strokeColor: '#FFFFFF',
|
||||||
|
strokeWeight: 2,
|
||||||
|
scale: 1.8,
|
||||||
|
anchor: new google.maps.Point(12, 24)
|
||||||
|
};
|
||||||
|
marker = new google.maps.Marker({
|
||||||
|
position: { lat, lng: lon },
|
||||||
|
map: mapInstanceRef.current,
|
||||||
|
title: regNo || vehicleInfo?.registration_number || 'Ambulance',
|
||||||
|
icon: svgIcon,
|
||||||
|
animation: google.maps.Animation.DROP
|
||||||
|
});
|
||||||
|
marker.addListener('click', () => {
|
||||||
|
if (vehicleInfo) centerOnVehicle(vehicleInfo);
|
||||||
|
});
|
||||||
|
markersRef.current[vId] = marker;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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()) ||
|
||||||
|
(v.brand && v.brand.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||||||
|
(v.model && v.model.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', height: 'calc(100vh - 128px)', gap: '20px', color: '#0F172A', fontFamily: 'sans-serif' }}>
|
||||||
|
|
||||||
|
{/* Sidebar: Vehicles List Panel */}
|
||||||
|
<div style={{
|
||||||
|
width: '380px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid rgba(15, 23, 42, 0.08)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Header / Stats */}
|
||||||
|
<div style={{ padding: '20px', borderBottom: '1px solid rgba(15, 23, 42, 0.06)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '14px' }}>
|
||||||
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 900, color: '#0F172A', margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Radio size={18} color="#06B6D4" style={{ animation: 'pulse 2s infinite' }} /> Active Ambulances
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.72rem', background: socketStatus === 'connected' ? '#ECFDF5' : '#FEF2F2', padding: '4px 10px', borderRadius: '50px', border: `1px solid ${socketStatus === 'connected' ? '#A7F3D0' : '#FCA5A5'}`, color: socketStatus === 'connected' ? '#047857' : '#B91C1C', fontWeight: 800 }}>
|
||||||
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: socketStatus === 'connected' ? '#10B981' : '#EF4444' }} />
|
||||||
|
{socketStatus === 'connected' ? 'Connected' : socketStatus === 'connecting' ? 'Reconnecting' : 'Disconnected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Mini Stats */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px' }}>
|
||||||
|
<div style={{ background: '#F8FAFC', padding: '8px 10px', borderRadius: '10px', border: '1px solid rgba(15, 23, 42, 0.04)', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '1.2rem', fontWeight: 900, color: '#0F172A' }}>{totalVehicles}</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#64748B', marginTop: '2px', textTransform: 'uppercase' }}>Total</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#FFF7ED', padding: '8px 10px', borderRadius: '10px', border: '1px solid #FFEDD5', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '1.2rem', fontWeight: 900, color: '#F97316' }}>{busyCount}</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#EA580C', marginTop: '2px', textTransform: 'uppercase' }}>Busy</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#ECFDF5', padding: '8px 10px', borderRadius: '10px', border: '1px solid #D1FAE5', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '1.2rem', fontWeight: 900, color: '#10B981' }}>{availableCount}</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#059669', marginTop: '2px', textTransform: 'uppercase' }}>Available</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unified borderless search bar */}
|
||||||
|
<div style={{ padding: '16px 20px', borderBottom: '1px solid rgba(15, 23, 42, 0.06)' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isSearchFocused ? '1px solid #06B6D4' : '1px solid #E2E8F0',
|
||||||
|
boxShadow: isSearchFocused ? '0 0 0 3px rgba(6, 182, 212, 0.15)' : 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<Search size={15} color={isSearchFocused ? "#06B6D4" : "#94A3B8"} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search registration or model..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
className="stations-search-input"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
outline: 'none',
|
||||||
|
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', padding: '2px' }}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable List Container */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 20px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '160px', color: '#64748B', gap: '10px' }}>
|
||||||
|
<Loader2 size={24} className="spin" />
|
||||||
|
<span style={{ fontSize: '0.8rem' }}>Loading fleet list...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '32px 10px', color: '#64748B' }}>
|
||||||
|
<Truck size={36} style={{ opacity: 0.15, marginBottom: '10px' }} />
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>No vehicles found</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && filtered.length > 0 && filtered.map(v => {
|
||||||
|
const sc = getStatusCfg(v.status);
|
||||||
|
const isSelected = selectedVehicle?.id === v.id;
|
||||||
|
const hasCoords = parseFloat(v.gps_lat as string) !== 0 && parseFloat(v.gps_lon as string) !== 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => { if (hasCoords) centerOnVehicle(v); }}
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isSelected ? '1px solid #06B6D4' : '1px solid rgba(15, 23, 42, 0.05)',
|
||||||
|
background: isSelected ? 'rgba(6,182,212,0.03)' : '#FFFFFF',
|
||||||
|
cursor: hasCoords ? 'pointer' : 'default',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (hasCoords && !isSelected) e.currentTarget.style.border = '1px solid rgba(6,182,212,0.3)'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.border = '1px solid rgba(15, 23, 42, 0.05)'; }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.62rem', fontWeight: 900, color: '#64748B', textTransform: 'uppercase' }}>
|
||||||
|
{v.vehicle_type} UNIT
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.92rem', fontWeight: 800, color: '#0F172A', marginTop: '2px' }}>
|
||||||
|
{v.registration_number}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '3px 8px', borderRadius: '6px', fontSize: '0.62rem', fontWeight: 900, color: sc.color, background: sc.bg, border: `1px solid ${sc.border}` }}>
|
||||||
|
{sc.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#475569' }}>
|
||||||
|
{v.brand} {v.model}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{v.activeShift?.staff?.user?.name && (
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#64748B', marginTop: '6px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<Check size={12} color="#10B981" /> Active Pilot: <strong>{v.activeShift.staff.user.name}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '10px', borderTop: '1px solid rgba(15, 23, 42, 0.04)', paddingTop: '8px' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#64748B', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<MapPin size={11} color={hasCoords ? '#06B6D4' : '#94A3B8'} />
|
||||||
|
{hasCoords ? `${parseFloat(v.gps_lat as string).toFixed(4)}, ${parseFloat(v.gps_lon as string).toFixed(4)}` : 'Coords Unavailable'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasCoords && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); centerOnVehicle(v); }}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: '#06B6D4', fontSize: '0.68rem', fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '2px' }}
|
||||||
|
>
|
||||||
|
<Compass size={11} /> TRACK
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
<button
|
||||||
|
onClick={fetchVehicles}
|
||||||
|
style={{
|
||||||
|
padding: '10px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#475569',
|
||||||
|
border: '1px solid #CBD5E1',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Panel: Google Map Canvas */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid rgba(15, 23, 42, 0.08)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
position: 'relative',
|
||||||
|
boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{/* Loading Overlay */}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: '#FFFFFF', zIndex: 10, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '14px' }}>
|
||||||
|
<Loader2 size={36} className="spin" color="#06B6D4" />
|
||||||
|
<h3 style={{ fontSize: '1.05rem', fontWeight: 800, color: '#0F172A', margin: 0 }}>Initializing Google Maps...</h3>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#64748B', margin: 0 }}>Establishing secure connection to Dispatch Gateway</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map div container */}
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dynamic Float Controls */}
|
||||||
|
<div style={{ position: 'absolute', top: '16px', left: '16px', zIndex: 5, background: 'rgba(15, 23, 42, 0.95)', padding: '10px 14px', borderRadius: '10px', color: '#FFFFFF', fontSize: '0.72rem', display: 'flex', alignItems: 'center', gap: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.2)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
|
<Activity size={14} color="#06B6D4" style={{ animation: 'pulse 1.5s infinite' }} />
|
||||||
|
<span>Real-time Telemetry Active • Channel: <strong>fleet:location_updated</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.92); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
UserCheck,
|
|
||||||
Clock,
|
|
||||||
Building2,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
LogOut,
|
|
||||||
RefreshCw,
|
|
||||||
X,
|
|
||||||
ChevronRight,
|
|
||||||
Activity,
|
|
||||||
BedDouble,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { hospitalApi } from '../../api/hospital';
|
|
||||||
|
|
||||||
interface AdmissionsBoardProps {
|
|
||||||
onRefresh?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AdmissionsBoard: React.FC<AdmissionsBoardProps> = ({ onRefresh }) => {
|
|
||||||
const [admissions, setAdmissions] = useState<any[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
|
||||||
const [dischargingId, setDischargingId] = useState<string | null>(null);
|
|
||||||
const [confirmDischargeId, setConfirmDischargeId] = useState<string | null>(null);
|
|
||||||
const [apiStats, setApiStats] = useState({ total: 0, admitted: 0, discharged: 0 });
|
|
||||||
const [successMsg, setSuccessMsg] = useState('');
|
|
||||||
|
|
||||||
const loadAdmissions = async (filter = statusFilter) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
|
||||||
if (!token) return;
|
|
||||||
const res = await hospitalApi.getAdmissions(token, 1, 50, filter);
|
|
||||||
console.log('[Admissions] API Response:', res);
|
|
||||||
|
|
||||||
let items: any[] = [];
|
|
||||||
const data = res?.data || res;
|
|
||||||
|
|
||||||
if (data?.items && Array.isArray(data.items)) {
|
|
||||||
items = data.items;
|
|
||||||
} else if (Array.isArray(data)) {
|
|
||||||
items = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAdmissions(items);
|
|
||||||
|
|
||||||
if (data?.stats) {
|
|
||||||
setApiStats(data.stats);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch admissions:', err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadAdmissions();
|
|
||||||
const interval = setInterval(() => loadAdmissions(), 20000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [statusFilter]);
|
|
||||||
|
|
||||||
const handleDischarge = async (admissionId: string) => {
|
|
||||||
setDischargingId(admissionId);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
|
||||||
const res = await hospitalApi.dischargePatient(admissionId, token);
|
|
||||||
console.log('[Admissions] Discharge Response:', res);
|
|
||||||
|
|
||||||
if (res?.error) {
|
|
||||||
alert('Discharge failed: ' + (res.error.message || 'Unknown error'));
|
|
||||||
} else {
|
|
||||||
setSuccessMsg('Patient discharged successfully');
|
|
||||||
setTimeout(() => setSuccessMsg(''), 3000);
|
|
||||||
setConfirmDischargeId(null);
|
|
||||||
loadAdmissions();
|
|
||||||
if (onRefresh) onRefresh();
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
alert('Error discharging patient: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
setDischargingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stats = useMemo(() => ({
|
|
||||||
total: apiStats.total,
|
|
||||||
admitted: apiStats.admitted,
|
|
||||||
discharged: apiStats.discharged,
|
|
||||||
}), [apiStats]);
|
|
||||||
|
|
||||||
const filteredAdmissions = useMemo(() => {
|
|
||||||
return admissions.filter(admission => {
|
|
||||||
const patientName = admission.patient?.name || '';
|
|
||||||
const deptName = admission.department?.name || '';
|
|
||||||
const matchesSearch =
|
|
||||||
patientName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
deptName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
admission.patient_id?.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
|
|
||||||
// The API already filters by status if not 'ALL',
|
|
||||||
// but we keep this for consistency and safety.
|
|
||||||
const matchesStatus = statusFilter === 'ALL' || admission.status === statusFilter;
|
|
||||||
return matchesSearch && matchesStatus;
|
|
||||||
});
|
|
||||||
}, [admissions, searchQuery, statusFilter]);
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string | null) => {
|
|
||||||
if (!dateStr) return '--';
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }) +
|
|
||||||
' ' + d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTimeSince = (dateStr: string | null) => {
|
|
||||||
if (!dateStr) return '--';
|
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
|
||||||
const hours = Math.floor(diff / 3600000);
|
|
||||||
const mins = Math.floor((diff % 3600000) / 60000);
|
|
||||||
if (hours > 24) return Math.floor(hours / 24) + 'd ' + (hours % 24) + 'h';
|
|
||||||
if (hours > 0) return hours + 'h ' + mins + 'm';
|
|
||||||
return mins + 'm';
|
|
||||||
};
|
|
||||||
|
|
||||||
const statuses = ['ALL', 'ADMITTED', 'DISCHARGED'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="module-content"
|
|
||||||
style={{ background: 'var(--tactical-bg)', minHeight: '100%', padding: '24px' }}
|
|
||||||
>
|
|
||||||
{/* Success Toast */}
|
|
||||||
{successMsg && (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', top: 30, left: '50%', transform: 'translateX(-50%)', zIndex: 9999,
|
|
||||||
background: '#10b981', color: '#fff', padding: '12px 24px', borderRadius: '12px',
|
|
||||||
fontWeight: 800, fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: '8px',
|
|
||||||
boxShadow: '0 8px 24px rgba(16, 185, 129, 0.3)',
|
|
||||||
}}>
|
|
||||||
<CheckCircle2 size={18} /> {successMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metrics Row */}
|
|
||||||
<div className="metrics-row-modern">
|
|
||||||
{[
|
|
||||||
{ label: 'Total Records', count: stats.total, icon: <Activity size={20} />, color: '#0ea5e9' },
|
|
||||||
{ label: 'Currently Admitted', count: stats.admitted, icon: <BedDouble size={20} />, color: '#8b5cf6' },
|
|
||||||
{ label: 'Discharged', count: stats.discharged, icon: <CheckCircle2 size={20} />, color: '#10b981' },
|
|
||||||
].map((m, i) => (
|
|
||||||
<div key={i} className="metric-card-premium">
|
|
||||||
<div className="metric-icon-wrap" style={{ color: m.color }}>
|
|
||||||
{m.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: '#0f172a', lineHeight: 1 }}>{m.count}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '4px' }}>{m.label}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header & Filters */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', marginTop: '8px' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#8b5cf6', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.05em' }}>
|
|
||||||
<UserCheck size={14} /> ADMISSIONS REGISTRY
|
|
||||||
</div>
|
|
||||||
<h2 style={{ margin: '8px 0 0 0', fontSize: '1.5rem', fontWeight: 700, color: '#1e293b', letterSpacing: '-0.02em' }}>Ward Management</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
|
||||||
<div className="search-mini" style={{ width: '280px', background: '#f1f5f9', border: '1px solid #e2e8f0' }}>
|
|
||||||
<Search size={16} color="#64748b" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search patient, department..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
style={{ fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setup-nav" style={{ background: '#f1f5f9', padding: '4px', borderRadius: '12px' }}>
|
|
||||||
{statuses.map(status => (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
className={`setup-nav-item ${statusFilter === status ? 'active' : ''}`}
|
|
||||||
onClick={() => setStatusFilter(status)}
|
|
||||||
style={{ fontSize: '0.7rem', fontWeight: 700, padding: '6px 14px' }}
|
|
||||||
>
|
|
||||||
{status.replace('_', ' ')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={loadAdmissions}
|
|
||||||
style={{
|
|
||||||
width: '40px', height: '40px', borderRadius: '12px', border: '1px solid #e2e8f0',
|
|
||||||
background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: 'pointer', transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
title="Refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} color="#64748b" className={isLoading ? 'spin' : ''} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{isLoading && admissions.length === 0 ? (
|
|
||||||
<div style={{ padding: '80px 40px', textAlign: 'center' }}>
|
|
||||||
<div className="spin" style={{ width: 32, height: 32, border: '3px solid #e2e8f0', borderTop: '3px solid #8b5cf6', borderRadius: '50%', margin: '0 auto 16px auto' }} />
|
|
||||||
<p style={{ color: '#64748b', fontWeight: 600 }}>Loading admissions...</p>
|
|
||||||
</div>
|
|
||||||
) : filteredAdmissions.length === 0 ? (
|
|
||||||
<div style={{ padding: '80px 40px', textAlign: 'center', background: '#f8fafc', borderRadius: '32px', border: '2px dashed #e2e8f0' }}>
|
|
||||||
<div style={{ width: 80, height: 80, borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px auto', boxShadow: '0 10px 25px rgba(0,0,0,0.05)' }}>
|
|
||||||
<BedDouble size={32} color="#94a3b8" />
|
|
||||||
</div>
|
|
||||||
<h3 style={{ color: '#1e293b', margin: '0 0 8px 0', fontWeight: 800 }}>No Admissions Found</h3>
|
|
||||||
<p style={{ color: '#64748b', fontSize: '0.9rem', maxWidth: '300px', margin: '8px auto 0 auto' }}>
|
|
||||||
{searchQuery || statusFilter !== 'ALL' ? 'No records match your current filters.' : 'No patients have been admitted yet.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{filteredAdmissions.map((admission) => (
|
|
||||||
<motion.div
|
|
||||||
key={admission.id}
|
|
||||||
layout
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
style={{
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: '20px',
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Status indicator bar */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: '4px',
|
|
||||||
background: admission.status === 'ADMITTED' ? '#8b5cf6' : '#10b981',
|
|
||||||
borderRadius: '20px 0 0 20px',
|
|
||||||
}} />
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
padding: '24px 24px 24px 28px',
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr 1fr auto',
|
|
||||||
gap: '24px',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
{/* Patient Info */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Patient</div>
|
|
||||||
<div style={{ fontSize: '1.05rem', fontWeight: 800, color: '#1e293b' }}>
|
|
||||||
{admission.patient?.name || 'Unknown'}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '6px', flexWrap: 'wrap' }}>
|
|
||||||
<span style={{
|
|
||||||
background: admission.patient?.triage_code === 'RED' ? 'hsla(0, 84%, 60%, 0.1)' : 'hsla(38, 92%, 50%, 0.1)',
|
|
||||||
color: admission.patient?.triage_code === 'RED' ? '#ef4444' : '#f59e0b',
|
|
||||||
padding: '2px 8px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 800,
|
|
||||||
}}>
|
|
||||||
{admission.patient?.triage_code || '--'}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: '#64748b', fontSize: '0.75rem', fontWeight: 600 }}>
|
|
||||||
{admission.patient?.age || '--'}Y • {admission.patient?.gender || '--'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{admission.patient?.chief_complaint && (
|
|
||||||
<div style={{ marginTop: '6px', fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
|
|
||||||
{admission.patient.chief_complaint}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Department Info */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Department</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<Building2 size={16} color="#8b5cf6" />
|
|
||||||
<span style={{ fontSize: '0.95rem', fontWeight: 700, color: '#1e293b' }}>
|
|
||||||
{admission.department?.name || '--'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600, marginTop: '6px' }}>
|
|
||||||
HOD: {admission.department?.headOfDepartment || '--'}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '12px', marginTop: '6px' }}>
|
|
||||||
<span style={{ fontSize: '0.7rem', color: '#64748b' }}>
|
|
||||||
Beds: <strong style={{ color: '#1e293b' }}>{admission.department?.occupiedBeds || 0}/{admission.department?.totalBedsCapacity || 0}</strong>
|
|
||||||
</span>
|
|
||||||
{admission.bed_type && (
|
|
||||||
<span style={{ fontSize: '0.7rem', background: '#f1f5f9', padding: '1px 6px', borderRadius: '4px', color: '#475569', fontWeight: 700 }}>
|
|
||||||
{admission.bed_type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Timeline</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
|
|
||||||
<Clock size={14} color="#8b5cf6" />
|
|
||||||
<span style={{ fontSize: '0.8rem', fontWeight: 700, color: '#1e293b' }}>
|
|
||||||
Admitted {formatDate(admission.admitted_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
|
|
||||||
Duration: <strong style={{ color: '#8b5cf6' }}>{getTimeSince(admission.admitted_at)}</strong>
|
|
||||||
</div>
|
|
||||||
{admission.discharged_at && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px' }}>
|
|
||||||
<CheckCircle2 size={14} color="#10b981" />
|
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#10b981' }}>
|
|
||||||
Discharged {formatDate(admission.discharged_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'flex-end' }}>
|
|
||||||
<span style={{
|
|
||||||
padding: '4px 12px', borderRadius: '8px', fontSize: '0.7rem', fontWeight: 800,
|
|
||||||
background: admission.status === 'ADMITTED' ? 'hsla(262, 83%, 58%, 0.1)' : 'hsla(152, 69%, 40%, 0.1)',
|
|
||||||
color: admission.status === 'ADMITTED' ? '#8b5cf6' : '#10b981',
|
|
||||||
}}>
|
|
||||||
{admission.status}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{admission.status === 'ADMITTED' && (
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDischargeId(admission.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: '6px',
|
|
||||||
padding: '8px 16px', borderRadius: '10px',
|
|
||||||
border: '1px solid #e2e8f0', background: '#fff',
|
|
||||||
color: '#ef4444', fontWeight: 700, fontSize: '0.8rem',
|
|
||||||
cursor: 'pointer', transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#fef2f2'; e.currentTarget.style.borderColor = '#fecaca'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = '#fff'; e.currentTarget.style.borderColor = '#e2e8f0'; }}
|
|
||||||
>
|
|
||||||
<LogOut size={14} /> Discharge
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Discharge Confirmation Modal */}
|
|
||||||
{createPortal(
|
|
||||||
<AnimatePresence>
|
|
||||||
{confirmDischargeId && (
|
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 10001, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={() => setConfirmDischargeId(null)}
|
|
||||||
style={{ position: 'absolute', inset: 0, background: 'rgba(15, 23, 42, 0.4)', backdropFilter: 'blur(2px)' }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
style={{
|
|
||||||
position: 'relative', background: '#fff', borderRadius: '20px',
|
|
||||||
width: '100%', maxWidth: '420px', overflow: 'hidden',
|
|
||||||
boxShadow: '0 20px 40px rgba(0,0,0,0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ padding: '24px', borderBottom: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fef2f2' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{ width: 40, height: 40, borderRadius: '12px', background: '#fee2e2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<AlertCircle size={20} color="#ef4444" />
|
|
||||||
</div>
|
|
||||||
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800, color: '#1e293b' }}>Confirm Discharge</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDischargeId(null)}
|
|
||||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#94a3b8' }}
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: '24px' }}>
|
|
||||||
{(() => {
|
|
||||||
const adm = admissions.find(a => a.id === confirmDischargeId);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p style={{ color: '#64748b', fontSize: '0.9rem', lineHeight: 1.5, margin: '0 0 20px 0' }}>
|
|
||||||
You are about to discharge the following patient. This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div style={{ background: '#f8fafc', borderRadius: '12px', padding: '16px', border: '1px solid #e2e8f0' }}>
|
|
||||||
<div style={{ fontSize: '1rem', fontWeight: 800, color: '#1e293b' }}>{adm?.patient?.name || 'Unknown'}</div>
|
|
||||||
<div style={{ fontSize: '0.8rem', color: '#64748b', marginTop: '4px' }}>
|
|
||||||
{adm?.department?.name || '--'} • Admitted {formatDate(adm?.admitted_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: '16px 24px', borderTop: '1px solid #f1f5f9', display: 'flex', justifyContent: 'flex-end', gap: '12px', background: '#f8fafc' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDischargeId(null)}
|
|
||||||
style={{
|
|
||||||
padding: '10px 20px', borderRadius: '10px', border: '1px solid #e2e8f0',
|
|
||||||
background: '#fff', fontWeight: 700, fontSize: '0.85rem', cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDischarge(confirmDischargeId!)}
|
|
||||||
disabled={!!dischargingId}
|
|
||||||
style={{
|
|
||||||
padding: '10px 24px', borderRadius: '10px', border: 'none',
|
|
||||||
background: '#ef4444', color: '#fff', fontWeight: 700, fontSize: '0.85rem',
|
|
||||||
cursor: dischargingId ? 'not-allowed' : 'pointer', opacity: dischargingId ? 0.7 : 1,
|
|
||||||
display: 'flex', alignItems: 'center', gap: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dischargingId ? (
|
|
||||||
<>
|
|
||||||
<div className="spin" style={{ width: 14, height: 14, border: '2px solid rgba(255,255,255,0.3)', borderTop: '2px solid #fff', borderRadius: '50%' }} />
|
|
||||||
Discharging...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LogOut size={14} /> Confirm Discharge
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { X, ChevronDown } from 'lucide-react';
|
|
||||||
|
|
||||||
interface DeptModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
deptFormData: {
|
|
||||||
name: string;
|
|
||||||
headOfDepartment: string;
|
|
||||||
totalBedsCapacity: number;
|
|
||||||
contactPhone: string;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
setDeptFormData: (data: any) => void;
|
|
||||||
allUsers: any[];
|
|
||||||
selectedHospital: any;
|
|
||||||
onSubmit: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DeptModal: React.FC<DeptModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
deptFormData,
|
|
||||||
setDeptFormData,
|
|
||||||
allUsers,
|
|
||||||
selectedHospital,
|
|
||||||
onSubmit,
|
|
||||||
}) => {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="premium-modal-overlay">
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
||||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
||||||
className="premium-modal-container"
|
|
||||||
>
|
|
||||||
<div className="modal-header-premium">
|
|
||||||
<h3>Add Department</h3>
|
|
||||||
<button className="modal-close-btn" onClick={onClose}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setup-form-modern">
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Department Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={deptFormData.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDeptFormData({ ...deptFormData, name: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="e.g. Emergency & Trauma"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Head of Department</label>
|
|
||||||
<div
|
|
||||||
className="custom-select-wrapper"
|
|
||||||
style={{ position: 'relative' }}
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
className="setup-input-premium custom-select"
|
|
||||||
value={deptFormData.headOfDepartment}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDeptFormData({
|
|
||||||
...deptFormData,
|
|
||||||
headOfDepartment: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
style={{ width: '100%', appearance: 'none' }}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select Head of Department
|
|
||||||
</option>
|
|
||||||
{allUsers
|
|
||||||
.filter(
|
|
||||||
(u) =>
|
|
||||||
u.hospitalId ===
|
|
||||||
(selectedHospital?.rawUser?.hospitalId ||
|
|
||||||
selectedHospital?.id) ||
|
|
||||||
u.organisationId ===
|
|
||||||
selectedHospital?.rawUser?.organisationId
|
|
||||||
)
|
|
||||||
.map((u, i) => (
|
|
||||||
<option key={i} value={u.name || u.username}>
|
|
||||||
{u.name || u.username} ({u.roles[0]?.replace('_', ' ')})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronDown
|
|
||||||
size={14}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: '12px',
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="form-group-premium"
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr',
|
|
||||||
gap: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label>Capacity (Beds)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="setup-input-premium"
|
|
||||||
style={{ width: '100%', marginTop: '10px' }}
|
|
||||||
value={deptFormData.totalBedsCapacity}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDeptFormData({
|
|
||||||
...deptFormData,
|
|
||||||
totalBedsCapacity: parseInt(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Contact Phone</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
style={{ width: '100%', marginTop: '10px' }}
|
|
||||||
value={deptFormData.contactPhone}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDeptFormData({
|
|
||||||
...deptFormData,
|
|
||||||
contactPhone: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="+91-..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-footer-premium">
|
|
||||||
<button className="btn-secondary-glass" onClick={onClose}>
|
|
||||||
CANCEL
|
|
||||||
</button>
|
|
||||||
<button className="btn-primary-glass" onClick={onSubmit}>
|
|
||||||
ADD DEPARTMENT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,633 +0,0 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
Activity,
|
|
||||||
Wind,
|
|
||||||
Video,
|
|
||||||
Zap,
|
|
||||||
ShieldAlert,
|
|
||||||
Database,
|
|
||||||
Radio,
|
|
||||||
Clock,
|
|
||||||
ChevronRight,
|
|
||||||
TrendingUp,
|
|
||||||
Filter,
|
|
||||||
AlertCircle,
|
|
||||||
Truck,
|
|
||||||
CheckCircle2,
|
|
||||||
X,
|
|
||||||
LayoutDashboard
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { hospitalApi } from '../../api/hospital';
|
|
||||||
import {
|
|
||||||
ResponsiveContainer,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
|
||||||
AreaChart,
|
|
||||||
Area
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
interface EDMonitorProps {
|
|
||||||
incomingPatients: any[];
|
|
||||||
selectedHospital: any;
|
|
||||||
departments: any[];
|
|
||||||
onRefresh?: () => void;
|
|
||||||
onOpenBooking: () => void;
|
|
||||||
onOpenTelelink: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EDMonitor: React.FC<EDMonitorProps> = ({
|
|
||||||
incomingPatients,
|
|
||||||
selectedHospital,
|
|
||||||
departments,
|
|
||||||
onRefresh,
|
|
||||||
onOpenBooking,
|
|
||||||
onOpenTelelink,
|
|
||||||
}) => {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
||||||
const [assigningPatientId, setAssigningPatientId] = useState<string | null>(null);
|
|
||||||
const [isAdmitting, setIsAdmitting] = useState(false);
|
|
||||||
|
|
||||||
const toggleExpand = (id: string) => {
|
|
||||||
setExpandedId(expandedId === id ? null : id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdmit = async (patientId: string, departmentId: string) => {
|
|
||||||
setIsAdmitting(true);
|
|
||||||
try {
|
|
||||||
const patient = incomingPatients.find(p => p.id === patientId);
|
|
||||||
const realId = patient?.originalId || patientId;
|
|
||||||
|
|
||||||
const token = localStorage.getItem('teleems_token') || '';
|
|
||||||
const res = await hospitalApi.admitPatient(realId, departmentId, token);
|
|
||||||
if (res.status === 200 || res.status === 201) {
|
|
||||||
setAssigningPatientId(null);
|
|
||||||
if (onRefresh) onRefresh();
|
|
||||||
} else {
|
|
||||||
alert('Admission failed: ' + (res.message || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
alert('Error admitting patient: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
setIsAdmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
return {
|
|
||||||
total: incomingPatients.filter(p => p.status === 'IN_TRANSIT' || p.status === 'PATIENT_LOADED').length,
|
|
||||||
critical: incomingPatients.filter(p => (p.triage === 'RED' || p.triage === 'CRITICAL') && p.status !== 'HANDOFF_COMPLETE').length,
|
|
||||||
onsite: incomingPatients.filter(p => p.status === 'ARRIVED').length,
|
|
||||||
handoff: incomingPatients.filter(p => p.status === 'HANDOFF_COMPLETE').length,
|
|
||||||
};
|
|
||||||
}, [incomingPatients]);
|
|
||||||
|
|
||||||
const filteredPatients = useMemo(() => {
|
|
||||||
return incomingPatients.filter(patient => {
|
|
||||||
const matchesSearch =
|
|
||||||
patient.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
patient.mrn.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
patient.ambulanceId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
patient.complaint.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
|
|
||||||
const matchesStatus = statusFilter === 'ALL' || patient.status === statusFilter;
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
|
||||||
});
|
|
||||||
}, [incomingPatients, searchQuery, statusFilter]);
|
|
||||||
|
|
||||||
const statuses = ['ALL', 'PATIENT_LOADED', 'IN_TRANSIT', 'ARRIVED', 'HANDOFF_COMPLETE'];
|
|
||||||
|
|
||||||
const triageData = useMemo(() => {
|
|
||||||
const counts: any = { RED: 0, ORANGE: 0, YELLOW: 0, GREEN: 0, WHITE: 0 };
|
|
||||||
incomingPatients.forEach(p => {
|
|
||||||
const t = String(p.triage).toUpperCase();
|
|
||||||
if (counts[t] !== undefined) counts[t]++;
|
|
||||||
else counts.GREEN++; // fallback
|
|
||||||
});
|
|
||||||
return [
|
|
||||||
{ name: 'Critical', value: counts.RED, color: '#ef4444' },
|
|
||||||
{ name: 'Urgent', value: counts.ORANGE, color: '#f97316' },
|
|
||||||
{ name: 'Stable', value: counts.YELLOW, color: '#eab308' },
|
|
||||||
{ name: 'Minimal', value: counts.GREEN, color: '#22c55e' },
|
|
||||||
{ name: 'Routine', value: counts.WHITE, color: '#94a3b8' },
|
|
||||||
].filter(d => d.value > 0);
|
|
||||||
}, [incomingPatients]);
|
|
||||||
|
|
||||||
const loadData = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{ time: '08:00', load: Math.max(0, stats.total - 2) },
|
|
||||||
{ time: '10:00', load: Math.max(0, stats.total - 1) },
|
|
||||||
{ time: '12:00', load: stats.total + 1 },
|
|
||||||
{ time: 'NOW', load: stats.total },
|
|
||||||
];
|
|
||||||
}, [stats.total]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="module-content ed-monitor-tactical"
|
|
||||||
style={{ background: 'var(--tactical-bg)', minHeight: '100%', padding: '24px' }}
|
|
||||||
>
|
|
||||||
{/* Metrics Row */}
|
|
||||||
<div className="metrics-row-modern">
|
|
||||||
{[
|
|
||||||
{ label: 'Incoming', count: stats.total, icon: <Activity size={20} />, color: '#0ea5e9' },
|
|
||||||
{ label: 'Critical', count: stats.critical, icon: <AlertCircle size={20} />, color: '#ef4444' },
|
|
||||||
{ label: 'On-Site', count: stats.onsite, icon: <Truck size={20} />, color: '#f59e0b' },
|
|
||||||
{ label: 'Completed', count: stats.handoff, icon: <CheckCircle2 size={20} />, color: '#10b981' },
|
|
||||||
].map((m, i) => (
|
|
||||||
<div key={i} className="metric-card-premium">
|
|
||||||
<div className="metric-icon-wrap" style={{ color: m.color }}>
|
|
||||||
{m.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: '#0f172a', lineHeight: 1 }}>{m.count}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '4px' }}>{m.label}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tactical-board-container">
|
|
||||||
<div className="board-header-tactical" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '32px' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#ef4444', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.05em' }}>
|
|
||||||
<Radio size={14} /> LIVE TRIAGE DATA STREAM
|
|
||||||
</div>
|
|
||||||
<h2 style={{ margin: '8px 0 0 0', fontSize: '1.5rem', fontWeight: 700, color: '#1e293b', letterSpacing: '-0.02em' }}>Emergent Intake Board</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
|
||||||
<div className="search-mini" style={{ width: '300px', background: '#f1f5f9', border: '1px solid #e2e8f0' }}>
|
|
||||||
<Search size={16} color="#64748b" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Filter by Name, MRN, Unit..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
style={{ fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setup-nav" style={{ background: '#f1f5f9', padding: '4px', borderRadius: '12px' }}>
|
|
||||||
{statuses.map(status => (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
className={`setup-nav-item ${statusFilter === status ? 'active' : ''}`}
|
|
||||||
onClick={() => setStatusFilter(status)}
|
|
||||||
style={{ fontSize: '0.7rem', fontWeight: 700, padding: '6px 14px' }}
|
|
||||||
>
|
|
||||||
{status.replace('_', ' ')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ed-grid-modern">
|
|
||||||
<div className="triage-feed-container">
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{filteredPatients.length > 0 ? (
|
|
||||||
filteredPatients.map((patient) => (
|
|
||||||
<motion.div
|
|
||||||
key={patient.id}
|
|
||||||
layout
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
className="tactical-patient-card"
|
|
||||||
>
|
|
||||||
<div className={`card-status-indicator ${patient.triage}`} />
|
|
||||||
|
|
||||||
<div className="card-main-tactical">
|
|
||||||
{/* Unit & ETA */}
|
|
||||||
<div className="unit-info-hex">
|
|
||||||
<div className="unit-label-premium">UNIT: {patient.ambulanceId}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
|
|
||||||
<div style={{ width: 10, height: 10, background: patient.triage === 'RED' ? '#ef4444' : '#22c55e', borderRadius: '50%' }} />
|
|
||||||
<span style={{ fontSize: '0.9rem', fontWeight: 700, color: '#1e293b' }}>ETA {patient.eta}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.02em' }}>{patient.status.replace('_', ' ')}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Identity & Complaint */}
|
|
||||||
<div className="p-identity-block">
|
|
||||||
<div className="p-name-premium">{patient.name}</div>
|
|
||||||
<div className="p-meta-inline">
|
|
||||||
<span style={{ color: 'var(--accent-cyan)', background: 'rgba(14, 165, 233, 0.08)', padding: '2px 8px', borderRadius: '6px', fontSize: '0.7rem' }}>{patient.mrn}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{patient.age}Y</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{patient.gender}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '10px' }}>
|
|
||||||
<div className={`complaint-badge-tactical ${patient.triage === 'RED' ? 'CRITICAL' : ''}`}>
|
|
||||||
<TrendingUp size={14} />
|
|
||||||
{patient.complaint}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vitals Monitor */}
|
|
||||||
<div className="vitals-monitor-block">
|
|
||||||
<div className="v-node-tactical hr">
|
|
||||||
<span className="v-val">{patient.vitals.hr}</span>
|
|
||||||
<span className="v-lbl">HR</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ width: '1px', height: '30px', background: 'rgba(255,255,255,0.08)' }} />
|
|
||||||
<div className="v-node-tactical spo2">
|
|
||||||
<span className="v-val">{patient.vitals.spo2}</span>
|
|
||||||
<span className="v-lbl">SPO2</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ width: '1px', height: '30px', background: 'rgba(255,255,255,0.08)' }} />
|
|
||||||
<div className="v-node-tactical bp">
|
|
||||||
<span className="v-val">{patient.vitals.bp}</span>
|
|
||||||
<span className="v-lbl">BP</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="tactical-actions">
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button className="action-btn-tactical primary" onClick={onOpenTelelink} style={{ flex: 1 }}>
|
|
||||||
<Video size={14} /> JOIN
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="action-btn-tactical"
|
|
||||||
onClick={() => toggleExpand(patient.id)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
VIEW DATA
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
|
||||||
<button className="action-btn-tactical" onClick={() => setAssigningPatientId(patient.id)} style={{ flex: 1, background: '#f8fafc', color: 'var(--accent-cyan)', border: '1px solid var(--accent-cyan-soft)' }}>
|
|
||||||
<LayoutDashboard size={14} /> ASSIGN
|
|
||||||
</button>
|
|
||||||
{/* <button className="action-btn-tactical" onClick={onOpenBooking} style={{ flex: 1 }}>
|
|
||||||
TRIAGE REG.
|
|
||||||
</button> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div style={{ padding: '100px 40px', textAlign: 'center', background: '#f8fafc', borderRadius: '32px', border: '2px dashed #e2e8f0' }}>
|
|
||||||
<div style={{ width: 80, height: 80, borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px auto', boxShadow: '0 10px 25px rgba(0,0,0,0.05)' }}>
|
|
||||||
<Filter size={32} color="#94a3b8" />
|
|
||||||
</div>
|
|
||||||
<h3 style={{ color: '#1e293b', margin: '0 0 8px 0', fontWeight: 800 }}>Board Stabilized</h3>
|
|
||||||
<p style={{ color: '#64748b', fontSize: '0.9rem', maxWidth: '300px', margin: '8px auto 0 auto' }}>All dispatches accounted for. No incoming units match current filters.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ed-intel-sidebar" style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
||||||
<div className="hub-card premium-ops" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: '24px', padding: '24px' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginBottom: '20px' }}>Triage Distribution</div>
|
|
||||||
<div style={{ height: '180px', width: '100%' }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={triageData}
|
|
||||||
innerRadius={55}
|
|
||||||
outerRadius={75}
|
|
||||||
paddingAngle={5}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{triageData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
|
||||||
itemStyle={{ fontSize: '12px', fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '10px', flexWrap: 'wrap' }}>
|
|
||||||
{triageData.map((d, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: d.color }} />
|
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#64748b' }}>{d.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hub-card premium-ops" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: '24px', padding: '24px' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginBottom: '20px' }}>Inflow Velocity</div>
|
|
||||||
<div style={{ height: '120px', width: '100%' }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={loadData}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorLoad" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.3}/>
|
|
||||||
<stop offset="95%" stopColor="#0ea5e9" stopOpacity={0}/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ borderRadius: '10px', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}
|
|
||||||
labelStyle={{ fontSize: '10px', color: '#94a3b8' }}
|
|
||||||
/>
|
|
||||||
<Area type="monotone" dataKey="load" stroke="#0ea5e9" fillOpacity={1} fill="url(#colorLoad)" strokeWidth={3} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hub-card premium-ops" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: '24px', padding: '24px' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#ef4444', letterSpacing: '0.15em', textTransform: 'uppercase', marginBottom: '20px' }}>System Alerts</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
<button className="code-btn-modern red" style={{ height: '60px' }}>
|
|
||||||
<div className="c-info">
|
|
||||||
<strong style={{ fontSize: '1rem' }}>CODE RED</strong>
|
|
||||||
<span>STAT Response Required</span>
|
|
||||||
</div>
|
|
||||||
<Zap size={20} fill="#fff" />
|
|
||||||
</button>
|
|
||||||
<button className="code-btn-modern trauma" style={{ height: '60px', background: 'linear-gradient(135deg, #f59e0b, #d97706)' }}>
|
|
||||||
<div className="c-info">
|
|
||||||
<strong style={{ fontSize: '1rem' }}>TRAUMA STAT</strong>
|
|
||||||
<span>Activation Sequence</span>
|
|
||||||
</div>
|
|
||||||
<ShieldAlert size={20} fill="#fff" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createPortal(
|
|
||||||
<AnimatePresence>
|
|
||||||
{expandedId && (
|
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={() => setExpandedId(null)}
|
|
||||||
style={{ position: 'absolute', inset: 0, background: 'rgba(15, 23, 42, 0.8)', backdropFilter: 'blur(4px)' }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '1000px',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const patient = incomingPatients.find(p => p.id === expandedId);
|
|
||||||
if (!patient) return null;
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
{/* Modal Header */}
|
|
||||||
<div style={{ padding: '24px', borderBottom: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
|
||||||
<div className={`card-status-indicator ${patient.triage}`} style={{ width: '4px', height: '40px', borderRadius: '4px' }} />
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: '#1e293b' }}>{patient.name} <span style={{ color: '#94a3b8', fontWeight: 500, fontSize: '1rem' }}>• {patient.mrn}</span></h3>
|
|
||||||
<div style={{ fontSize: '0.85rem', color: '#64748b', marginTop: '4px' }}>
|
|
||||||
{patient.age}Y • {patient.gender} • Unit {patient.ambulanceId} • Status: {patient.status.replace('_', ' ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpandedId(null)}
|
|
||||||
style={{ width: '40px', height: '40px', borderRadius: '12px', border: '1px solid #e2e8f0', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<X size={20} color="#64748b" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal Body */}
|
|
||||||
<div style={{ padding: '32px', overflowY: 'auto' }}>
|
|
||||||
<div className="detail-module-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '32px' }}>
|
|
||||||
{/* Clinical Module */}
|
|
||||||
<div className="detail-module">
|
|
||||||
<h5 style={{ fontSize: '0.7rem', color: '#94a3b8', textTransform: 'uppercase', marginBottom: '16px', letterSpacing: '0.1em', fontWeight: 800 }}>Clinical Assessment</h5>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
||||||
<div style={{ background: '#f8fafc', padding: '16px', borderRadius: '12px', border: '1px solid #e2e8f0' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#64748b', marginBottom: '6px' }}>GCS TOTAL</div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#1e293b' }}>{patient.gcs?.total || '--'}</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#94a3b8', marginTop: '4px' }}>E:{patient.gcs?.eye} V:{patient.gcs?.verbal} M:{patient.gcs?.motor}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: '#f8fafc', padding: '16px', borderRadius: '12px', border: '1px solid #e2e8f0' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#64748b', marginBottom: '6px' }}>AVPU</div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#1e293b' }}>{patient.avpu}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: '#f8fafc', padding: '16px', borderRadius: '12px', border: '1px solid #e2e8f0', gridColumn: 'span 2' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#64748b', marginBottom: '8px' }}>PUPILS</div>
|
|
||||||
<div style={{ display: 'flex', gap: '30px' }}>
|
|
||||||
<div style={{ fontSize: '1rem', fontWeight: 700 }}><span style={{ color: '#94a3b8' }}>LEFT:</span> {patient.pupils?.left || '--'}</div>
|
|
||||||
<div style={{ fontSize: '1rem', fontWeight: 700 }}><span style={{ color: '#94a3b8' }}>RIGHT:</span> {patient.pupils?.right || '--'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HPI Module */}
|
|
||||||
<div className="detail-module">
|
|
||||||
<h5 style={{ fontSize: '0.7rem', color: '#94a3b8', textTransform: 'uppercase', marginBottom: '16px', letterSpacing: '0.1em', fontWeight: 800 }}>History of Illness (HPI)</h5>
|
|
||||||
<div style={{ background: '#fff', padding: '20px', borderRadius: '16px', border: '1px solid #e2e8f0', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)' }}>
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>CHIEF COMPLAINT</div>
|
|
||||||
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#1e293b' }}>{patient.complaint}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>ONSET</div>
|
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{patient.hpi?.onset || '--'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>DURATION</div>
|
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{patient.hpi?.duration || '--'}m</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>SEVERITY</div>
|
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{patient.hpi?.severity || '--'}/10</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>CHARACTER</div>
|
|
||||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{patient.hpi?.character || '--'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Medical Background */}
|
|
||||||
<div className="detail-module">
|
|
||||||
<h5 style={{ fontSize: '0.7rem', color: '#94a3b8', textTransform: 'uppercase', marginBottom: '16px', letterSpacing: '0.1em', fontWeight: 800 }}>Medical Records</h5>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{[
|
|
||||||
{ label: 'ALLERGIES', val: patient.allergies.map((a: any) => a.name).join(', '), color: '#fee2e2', textColor: '#b91c1c' },
|
|
||||||
{ label: 'MEDICATIONS', val: patient.medications.join(', '), color: '#f1f5f9', textColor: '#475569' },
|
|
||||||
{ label: 'CONDITIONS', val: patient.conditions.join(', '), color: '#f1f5f9', textColor: '#475569' },
|
|
||||||
{ label: 'SURGERIES', val: patient.surgeries.join(', '), color: '#f1f5f9', textColor: '#475569' },
|
|
||||||
].map((item, idx) => (
|
|
||||||
<div key={idx} style={{ background: item.val ? item.color : 'transparent', padding: '12px 16px', borderRadius: '10px', border: item.val ? 'none' : '1px dashed #e2e8f0' }}>
|
|
||||||
<div style={{ fontSize: '0.6rem', color: '#64748b', fontWeight: 800, marginBottom: '4px' }}>{item.label}</div>
|
|
||||||
<div style={{ fontSize: '0.9rem', color: item.textColor, fontWeight: 700 }}>{item.val || '--'}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Info Row */}
|
|
||||||
<div style={{ gridColumn: '1 / -1', borderTop: '1px solid #f1f5f9', paddingTop: '32px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', gap: '24px' }}>
|
|
||||||
<div style={{ background: '#f8fafc', padding: '20px', borderRadius: '16px', border: '1px solid #e2e8f0' }}>
|
|
||||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', marginBottom: '12px', fontWeight: 800 }}>INFORMER CONTACT</div>
|
|
||||||
<div style={{ display: 'flex', gap: '40px' }}>
|
|
||||||
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>NAME:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.informer?.name}</div></div>
|
|
||||||
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>RELATION:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.informer?.relation}</div></div>
|
|
||||||
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>PHONE:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.informer?.phone}</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: patient.mlc?.is_mlc ? '#fffef2' : '#f8fafc', padding: '20px', borderRadius: '16px', border: `1px solid ${patient.mlc?.is_mlc ? '#fef08a' : '#e2e8f0'}` }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
||||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontWeight: 800 }}>MLC RECORD</div>
|
|
||||||
{patient.mlc?.is_mlc && <span style={{ background: '#fef9c3', color: '#854d0e', fontSize: '0.6rem', fontWeight: 900, padding: '4px 8px', borderRadius: '6px' }}>LEGAL CLEARANCE REQ.</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '40px' }}>
|
|
||||||
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>FIR:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.mlc?.fir || '--'}</div></div>
|
|
||||||
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>STATION:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.mlc?.station || '--'}</div></div>
|
|
||||||
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>OFFICER:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.mlc?.officer || '--'}</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal Actions */}
|
|
||||||
<div style={{ padding: '24px', borderTop: '1px solid #f1f5f9', background: '#f8fafc', display: 'flex', justifyContent: 'flex-end', gap: '16px' }}>
|
|
||||||
<button className="action-btn-tactical" onClick={() => setExpandedId(null)} style={{ padding: '0 24px', height: '44px' }}>CLOSE REPORT</button>
|
|
||||||
<button className="action-btn-tactical primary" onClick={onOpenTelelink} style={{ padding: '0 32px', height: '44px' }}><Video size={18} /> INITIATE CALL</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Assignment Modal */}
|
|
||||||
{createPortal(
|
|
||||||
<AnimatePresence>
|
|
||||||
{assigningPatientId && (
|
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 10001, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={() => setAssigningPatientId(null)}
|
|
||||||
style={{ position: 'absolute', inset: 0, background: 'rgba(15, 23, 42, 0.4)', backdropFilter: 'blur(2px)' }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: '20px',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '450px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 20px 40px rgba(0,0,0,0.2)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ padding: '20px 24px', borderBottom: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc' }}>
|
|
||||||
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800, color: '#1e293b' }}>Assign Department</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setAssigningPatientId(null)}
|
|
||||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#94a3b8' }}
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: '24px', maxHeight: '400px', overflowY: 'auto' }}>
|
|
||||||
<div style={{ marginBottom: '16px', fontSize: '0.85rem', color: '#64748b' }}>
|
|
||||||
Select target clinical unit for: <strong>{incomingPatients.find(p => p.id === assigningPatientId)?.name}</strong>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{departments.length > 0 ? departments.map(dept => (
|
|
||||||
<button
|
|
||||||
key={dept.id}
|
|
||||||
disabled={isAdmitting}
|
|
||||||
onClick={() => handleAdmit(assigningPatientId, dept.id)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '14px 18px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
background: '#fff',
|
|
||||||
textAlign: 'left',
|
|
||||||
cursor: isAdmitting ? 'not-allowed' : 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent-cyan)'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = '#e2e8f0'}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: dept.isActive ? '#10b981' : '#cbd5e1' }} />
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#1e293b' }}>{dept.name}</div>
|
|
||||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>Available: {dept.availableBeds} Units</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight size={16} color="#94a3b8" />
|
|
||||||
</button>
|
|
||||||
)) : (
|
|
||||||
<div style={{ textAlign: 'center', padding: '20px', color: '#94a3b8', fontSize: '0.9rem' }}>
|
|
||||||
No active departments found.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAdmitting && (
|
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'rgba(255,255,255,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10 }}>
|
|
||||||
<div className="spin" style={{ width: 24, height: 24, border: '2px solid #e2e8f0', borderTop: '2px solid var(--accent-cyan)', borderRadius: '50%' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
Download,
|
|
||||||
Printer,
|
|
||||||
Clock,
|
|
||||||
User,
|
|
||||||
Activity,
|
|
||||||
CheckCircle2,
|
|
||||||
ShieldCheck,
|
|
||||||
ExternalLink,
|
|
||||||
ChevronRight,
|
|
||||||
TrendingUp,
|
|
||||||
Pill,
|
|
||||||
Syringe,
|
|
||||||
X,
|
|
||||||
Stethoscope
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface PCRRecord {
|
|
||||||
id: string;
|
|
||||||
date: string;
|
|
||||||
ambulance: string;
|
|
||||||
patient: string;
|
|
||||||
age: string;
|
|
||||||
gender: string;
|
|
||||||
triage: 'RED' | 'YELLOW' | 'GREEN';
|
|
||||||
status: 'RECEIVED' | 'PENDING_SIGN';
|
|
||||||
emt: string;
|
|
||||||
hospitalSign: string | null;
|
|
||||||
vitals: { hr: string; spo2: string; bp: string; rr: string };
|
|
||||||
meds: string[];
|
|
||||||
interventions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PCR_DETAIL_VIEW = ({ record, onClose }: { record: PCRRecord; onClose: () => void }) => {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="pcr-modal-overlay"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
||||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
||||||
className="pcr-detail-card"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="pcr-detail-header">
|
|
||||||
<div className="pcr-id-badge">
|
|
||||||
<FileText size={18} />
|
|
||||||
<span>RECORD {record.id}</span>
|
|
||||||
</div>
|
|
||||||
<button className="close-pcr-btn" onClick={onClose}><X size={20} /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pcr-detail-content">
|
|
||||||
<section className="pcr-section patient-bio">
|
|
||||||
<div className="section-label">PATIENT INFORMATION</div>
|
|
||||||
<div className="bio-grid">
|
|
||||||
<div className="bio-item"><span className="label">Full Name:</span> <span className="value">{record.patient}</span></div>
|
|
||||||
<div className="bio-item"><span className="label">Age/Gender:</span> <span className="value">{record.age} / {record.gender}</span></div>
|
|
||||||
<div className="bio-item"><span className="label">Ambulance:</span> <span className="value">{record.ambulance} (ALS)</span></div>
|
|
||||||
<div className="bio-item"><span className="label">Lead EMT:</span> <span className="value">{record.emt}</span></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="pcr-row-split">
|
|
||||||
<section className="pcr-section vitals-trends">
|
|
||||||
<div className="section-label"><TrendingUp size={14} /> VITAL SIGN TRENDS</div>
|
|
||||||
<div className="vitals-trend-grid">
|
|
||||||
<div className="trend-item">
|
|
||||||
<span className="label">HR (BPM)</span>
|
|
||||||
<div className="sparkline-placeholder" />
|
|
||||||
<span className="value">{record.vitals.hr}</span>
|
|
||||||
</div>
|
|
||||||
<div className="trend-item">
|
|
||||||
<span className="label">SpO2 (%)</span>
|
|
||||||
<div className="sparkline-placeholder green" />
|
|
||||||
<span className="value">{record.vitals.spo2}</span>
|
|
||||||
</div>
|
|
||||||
<div className="trend-item">
|
|
||||||
<span className="label">BP (mmHg)</span>
|
|
||||||
<span className="value">{record.vitals.bp}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="pcr-section interventions">
|
|
||||||
<div className="section-label"><Stethoscope size={14} /> INTERVENTIONS & MEDS</div>
|
|
||||||
<div className="med-list">
|
|
||||||
{record.meds.map((med, i) => (
|
|
||||||
<div key={i} className="med-item"><Pill size={12} /> {med}</div>
|
|
||||||
))}
|
|
||||||
{record.interventions.map((int, i) => (
|
|
||||||
<div key={i} className="med-item highlight"><Activity size={12} /> {int}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="pcr-section hms-integration">
|
|
||||||
<div className="section-label">TRANSFER OF CARE & SIGN-OFF</div>
|
|
||||||
<div className="sign-off-panel">
|
|
||||||
{record.status === 'PENDING_SIGN' ? (
|
|
||||||
<div className="pending-sign-area">
|
|
||||||
<p>I verify that the patient and the associated ePCR have been received by the clinical team.</p>
|
|
||||||
<button className="confirm-sign-btn">DIGITALLY SIGN & SYNC TO HMIS</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="completed-sign-area">
|
|
||||||
<ShieldCheck size={24} className="text-green-500" />
|
|
||||||
<div>
|
|
||||||
<div className="signed-by">Signed by: {record.hospitalSign}</div>
|
|
||||||
<div className="signed-at">Timestamp: {record.date} (Synced)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pcr-detail-footer">
|
|
||||||
<button className="pcr-action-btn primary"><Download size={16} /> DOWNLOAD PDF</button>
|
|
||||||
<button className="pcr-action-btn"><Printer size={16} /> PRINT RECORD</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EPCRRecords: React.FC = () => {
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [selectedPCR, setSelectedPCR] = useState<PCRRecord | null>(null);
|
|
||||||
|
|
||||||
const records: PCRRecord[] = [
|
|
||||||
{
|
|
||||||
id: 'PCR-8821',
|
|
||||||
date: '2026-05-05 12:30',
|
|
||||||
ambulance: 'KA-01-AM-102',
|
|
||||||
patient: 'Rajesh Khanna',
|
|
||||||
age: '45',
|
|
||||||
gender: 'M',
|
|
||||||
triage: 'RED',
|
|
||||||
status: 'RECEIVED',
|
|
||||||
emt: 'Arjun K.',
|
|
||||||
hospitalSign: 'Dr. Ramesh',
|
|
||||||
vitals: { hr: '124', spo2: '88', bp: '100/60', rr: '24' },
|
|
||||||
meds: ['Epinephrine 1mg', 'Amiodarone 300mg'],
|
|
||||||
interventions: ['CPR Performed', 'Intubation', 'IV Access Established']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'PCR-8815',
|
|
||||||
date: '2026-05-05 11:15',
|
|
||||||
ambulance: 'KA-05-MN-88',
|
|
||||||
patient: 'Unknown Male',
|
|
||||||
age: '28',
|
|
||||||
gender: 'M',
|
|
||||||
triage: 'RED',
|
|
||||||
status: 'PENDING_SIGN',
|
|
||||||
emt: 'Suman R.',
|
|
||||||
hospitalSign: null,
|
|
||||||
vitals: { hr: '142', spo2: '82', bp: '80/40', rr: '28' },
|
|
||||||
meds: ['Normal Saline 500ml', 'TXA 1g'],
|
|
||||||
interventions: ['C-Collar Applied', 'Hemorrhage Control', 'Oxygen (15L)']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'PCR-8798',
|
|
||||||
date: '2026-05-04 22:40',
|
|
||||||
ambulance: 'KA-03-KL-44',
|
|
||||||
patient: 'Amit Shah',
|
|
||||||
age: '58',
|
|
||||||
gender: 'M',
|
|
||||||
triage: 'GREEN',
|
|
||||||
status: 'RECEIVED',
|
|
||||||
emt: 'Kiran P.',
|
|
||||||
hospitalSign: 'Nurse Meera',
|
|
||||||
vitals: { hr: '82', spo2: '98', bp: '120/80', rr: '16' },
|
|
||||||
meds: [],
|
|
||||||
interventions: ['Splinting (Right Leg)']
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
className="module-content"
|
|
||||||
>
|
|
||||||
<div className="module-header-modern">
|
|
||||||
<div className="title-wrap">
|
|
||||||
<h3>ePCR RECEIPT & ARCHIVE</h3>
|
|
||||||
<div className="live-pill">
|
|
||||||
<span className="pulse" /> DIGITAL HANDOVER REPOSITORY
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="action-area-header">
|
|
||||||
<div className="search-mini">
|
|
||||||
<Search size={14} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by Patient, MRN, Incident ID..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button className="register-staff-btn-premium">
|
|
||||||
<Filter size={18} />
|
|
||||||
<span className="hide-mobile">FILTERS</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="epcr-stats-strip">
|
|
||||||
<div className="mini-stat">
|
|
||||||
<span className="label">TODAY'S ePCRs</span>
|
|
||||||
<span className="value">12</span>
|
|
||||||
</div>
|
|
||||||
<div className="mini-stat">
|
|
||||||
<span className="label">AWAITING SIGNATURE</span>
|
|
||||||
<span className="value-alert">01</span>
|
|
||||||
</div>
|
|
||||||
<div className="mini-stat">
|
|
||||||
<span className="label">SYNCED TO HMIS</span>
|
|
||||||
<span className="value">11</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="epcr-table-container">
|
|
||||||
<table className="staff-table-premium">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Record ID & Date</th>
|
|
||||||
<th>Ambulance & EMT</th>
|
|
||||||
<th>Patient Details</th>
|
|
||||||
<th>Triage & Clinical Status</th>
|
|
||||||
<th>Handoff Signature</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{records.map(record => (
|
|
||||||
<tr key={record.id} className="table-row-hover" onClick={() => setSelectedPCR(record)}>
|
|
||||||
<td>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div className="record-icon-mini"><FileText size={14} /></div>
|
|
||||||
<div>
|
|
||||||
<div className="record-id">{record.id}</div>
|
|
||||||
<div className="record-date">{record.date}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="record-meta">
|
|
||||||
<span className="reg-no">{record.ambulance}</span>
|
|
||||||
<span className="emt-name">{record.emt}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="record-patient">{record.patient}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="record-status-wrap">
|
|
||||||
<span className={`triage-tag triage-${record.triage.toLowerCase()}`}>{record.triage}</span>
|
|
||||||
<span className={`receive-status ${record.status.toLowerCase()}`}>
|
|
||||||
{record.status === 'RECEIVED' ? <CheckCircle2 size={12} /> : <Clock size={12} />}
|
|
||||||
{record.status.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{record.hospitalSign ? (
|
|
||||||
<div className="sign-badge">
|
|
||||||
<ShieldCheck size={12} />
|
|
||||||
{record.hospitalSign}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className="sign-action-btn">PENDING SIGN</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="action-row-mini">
|
|
||||||
<button className="tag-btn" title="View ePCR"><ExternalLink size={14} /></button>
|
|
||||||
<button className="tag-btn" title="Download PDF"><Download size={14} /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedPCR && <PCR_DETAIL_VIEW record={selectedPCR} onClose={() => setSelectedPCR(null)} />}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Truck,
|
|
||||||
MapPin,
|
|
||||||
Activity,
|
|
||||||
Droplet,
|
|
||||||
AlertTriangle,
|
|
||||||
Filter,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Navigation,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
ShieldCheck,
|
|
||||||
Search,
|
|
||||||
Users
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export const FleetView: React.FC = () => {
|
|
||||||
const [filterType, setFilterType] = useState('ALL');
|
|
||||||
const [isPrivateOnly, setIsPrivateOnly] = useState(false);
|
|
||||||
|
|
||||||
const fleet = [
|
|
||||||
{ id: 'AMB-01', type: 'ALS', status: 'RUNNING', patient: 'TR-1082', fuel: '75%', crew: 'EMT Arjun, Driver Som', eta: '4m', coords: [12.9716, 77.5946], network: 'PUBLIC' },
|
|
||||||
{ id: 'AMB-02', type: 'BLS', status: 'IDLE', patient: null, fuel: '92%', crew: 'EMT Suman, Driver Ravi', eta: '-', coords: [12.935, 77.6245], network: 'PRIVATE' },
|
|
||||||
{ id: 'AMB-03', type: 'Transport', status: 'BREAKDOWN', patient: null, fuel: '15%', crew: 'None', eta: '-', coords: [12.95, 77.6], alert: 'Engine Overheat', network: 'PUBLIC' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
className="module-content"
|
|
||||||
>
|
|
||||||
<div className="module-header-modern">
|
|
||||||
<div className="title-wrap">
|
|
||||||
<h3>FLEET VISIBILITY DASHBOARD</h3>
|
|
||||||
<div className="live-pill">
|
|
||||||
<span className="pulse" /> {fleet.filter(f => f.status === 'RUNNING').length} ACTIVE UNITS
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="action-area-header">
|
|
||||||
<div className="filter-group-premium">
|
|
||||||
<button className={`filter-chip ${!isPrivateOnly ? 'active' : ''}`} onClick={() => setIsPrivateOnly(false)}>NETWORK FLEET</button>
|
|
||||||
<button className={`filter-chip ${isPrivateOnly ? 'active' : ''}`} onClick={() => setIsPrivateOnly(true)}>PRIVATE FLEET</button>
|
|
||||||
</div>
|
|
||||||
<div className="search-mini">
|
|
||||||
<Search size={14} />
|
|
||||||
<input type="text" placeholder="Search Vehicle ID..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="fleet-visibility-grid-premium">
|
|
||||||
<div className="map-command-surface">
|
|
||||||
<div className="map-overlay-vignette" />
|
|
||||||
<div className="map-scan-lines" />
|
|
||||||
|
|
||||||
<div className="map-placeholder-premium">
|
|
||||||
<div className="map-coordinate-grid" />
|
|
||||||
|
|
||||||
{fleet.map(unit => (
|
|
||||||
<motion.div
|
|
||||||
key={unit.id}
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
whileHover={{ scale: 1.2, zIndex: 100 }}
|
|
||||||
className={`node-marker-premium status-${unit.status.toLowerCase()}`}
|
|
||||||
style={{
|
|
||||||
left: `${(unit.coords[1] - 77.5) * 600}px`,
|
|
||||||
top: `${(13.0 - unit.coords[0]) * 600}px`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="marker-core">
|
|
||||||
<Truck size={14} />
|
|
||||||
</div>
|
|
||||||
<div className="marker-ping" />
|
|
||||||
<div className="marker-label-premium">
|
|
||||||
<span className="id">{unit.id}</span>
|
|
||||||
<span className="type">{unit.type}</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="map-hud-controls">
|
|
||||||
<div className="hud-group">
|
|
||||||
<button className="hud-btn"><Navigation size={18} /></button>
|
|
||||||
<div className="hud-divider" />
|
|
||||||
<button className="hud-btn"><MapPin size={18} /></button>
|
|
||||||
</div>
|
|
||||||
<div className="hud-group zoom">
|
|
||||||
<button className="hud-btn">+</button>
|
|
||||||
<button className="hud-btn">-</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="fleet-mission-sidebar">
|
|
||||||
<div className="sidebar-head">
|
|
||||||
<div className="head-text">
|
|
||||||
<h4>ACTIVE ASSETS</h4>
|
|
||||||
<p>REGION: BENGALURU-NORTH-X2</p>
|
|
||||||
</div>
|
|
||||||
<div className="status-summary-mini">
|
|
||||||
<span className="dot active" />
|
|
||||||
<span className="val">{fleet.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="fleet-unit-stack">
|
|
||||||
{fleet.map(unit => (
|
|
||||||
<motion.div
|
|
||||||
key={unit.id}
|
|
||||||
whileHover={{ x: 6 }}
|
|
||||||
className={`unit-mission-card ${unit.alert ? 'critical' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="unit-card-header">
|
|
||||||
<div className="type-badge">{unit.type} UNIT</div>
|
|
||||||
<div className={`status-pill ${unit.status.toLowerCase()}`}>{unit.status}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="unit-card-body">
|
|
||||||
<h3 className="unit-id-large">{unit.id}</h3>
|
|
||||||
<div className="unit-stats-row">
|
|
||||||
<div className="u-stat">
|
|
||||||
<Droplet size={12} />
|
|
||||||
<span>{unit.fuel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="u-stat">
|
|
||||||
<Clock size={12} />
|
|
||||||
<span className="highlight-cyan">{unit.eta || '--'} ETA</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{unit.patient && (
|
|
||||||
<div className="mission-active-indicator">
|
|
||||||
<div className="m-label">ACTIVE MISSION</div>
|
|
||||||
<div className="m-id">{unit.patient}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="unit-card-footer">
|
|
||||||
<div className="crew-mini">
|
|
||||||
<Users size={12} />
|
|
||||||
<span>{unit.crew}</span>
|
|
||||||
</div>
|
|
||||||
<button className="unit-action-btn"><Eye size={14} /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{unit.alert && (
|
|
||||||
<div className="unit-card-alert">
|
|
||||||
<AlertTriangle size={14} />
|
|
||||||
<span>{unit.alert.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
TrendingUp,
|
|
||||||
BarChart,
|
|
||||||
PieChart,
|
|
||||||
Clock,
|
|
||||||
Calendar,
|
|
||||||
Download,
|
|
||||||
Filter,
|
|
||||||
Activity,
|
|
||||||
Users,
|
|
||||||
Video,
|
|
||||||
Zap,
|
|
||||||
ArrowUpRight,
|
|
||||||
ArrowDownRight,
|
|
||||||
Search,
|
|
||||||
FileBarChart,
|
|
||||||
BrainCircuit,
|
|
||||||
Database
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
BarChart as ReBarChart,
|
|
||||||
Bar,
|
|
||||||
Cell,
|
|
||||||
PieChart as RePieChart,
|
|
||||||
Pie,
|
|
||||||
LineChart,
|
|
||||||
Line
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
export const HospitalAnalytics: React.FC = () => {
|
|
||||||
const [timeRange, setTimeRange] = useState('7D');
|
|
||||||
|
|
||||||
const volumeData = [
|
|
||||||
{ name: '00:00', count: 5, baseline: 4 },
|
|
||||||
{ name: '04:00', count: 12, baseline: 8 },
|
|
||||||
{ name: '08:00', count: 28, baseline: 22 },
|
|
||||||
{ name: '12:00', count: 42, baseline: 35 },
|
|
||||||
{ name: '16:00', count: 38, baseline: 40 },
|
|
||||||
{ name: '20:00', count: 22, baseline: 18 },
|
|
||||||
{ name: '23:59', count: 8, baseline: 6 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const triageData = [
|
|
||||||
{ name: 'Red (Critical)', value: 25, color: 'hsl(0, 84%, 60%)' },
|
|
||||||
{ name: 'Yellow (Emergent)', value: 45, color: 'hsl(38, 92%, 50%)' },
|
|
||||||
{ name: 'Green (Standard)', value: 30, color: 'hsl(142, 70%, 45%)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const departmentEfficiency = [
|
|
||||||
{ dept: 'ER', load: 85, cap: 90 },
|
|
||||||
{ dept: 'ICU', load: 92, cap: 80 },
|
|
||||||
{ dept: 'Cardio', load: 60, cap: 75 },
|
|
||||||
{ dept: 'Trauma', load: 78, cap: 85 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="module-content analytics-v2"
|
|
||||||
>
|
|
||||||
<div className="module-header-modern">
|
|
||||||
<div className="title-wrap">
|
|
||||||
<div className="clinical-intel-badge">
|
|
||||||
<BrainCircuit size={16} />
|
|
||||||
CONSOLIDATED CLINICAL INTELLIGENCE
|
|
||||||
</div>
|
|
||||||
<h3>Hospital Command Insights</h3>
|
|
||||||
</div>
|
|
||||||
<div className="action-area-header">
|
|
||||||
<div className="intel-filters">
|
|
||||||
{['24H', '7D', '30D', 'ALL'].map(t => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
className={`intel-filter-btn ${timeRange === t ? 'active' : ''}`}
|
|
||||||
onClick={() => setTimeRange(t)}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button className="register-staff-btn-premium">
|
|
||||||
<FileBarChart size={18} />
|
|
||||||
<span className="hide-mobile">EXTRACT REPORT</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="intelligence-grid">
|
|
||||||
{/* Row 1: High Level KPIs */}
|
|
||||||
<div className="kpi-strip">
|
|
||||||
<div className="kpi-card-modern neon-blue">
|
|
||||||
<div className="kpi-icon"><Activity size={20} /></div>
|
|
||||||
<div className="kpi-info">
|
|
||||||
<label>System Utilization</label>
|
|
||||||
<div className="kpi-value-wrap">
|
|
||||||
<span className="value">84.2%</span>
|
|
||||||
<span className="trend positive">+2.4%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card-modern neon-purple">
|
|
||||||
<div className="kpi-icon"><Video size={20} /></div>
|
|
||||||
<div className="kpi-info">
|
|
||||||
<label>TeleLink Saturation</label>
|
|
||||||
<div className="kpi-value-wrap">
|
|
||||||
<span className="value">62/h</span>
|
|
||||||
<span className="trend positive">+18%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card-modern neon-green">
|
|
||||||
<div className="kpi-icon"><Users size={20} /></div>
|
|
||||||
<div className="kpi-info">
|
|
||||||
<label>Total Intake (v24)</label>
|
|
||||||
<div className="kpi-value-wrap">
|
|
||||||
<span className="value">342</span>
|
|
||||||
<span className="trend neutral">STABLE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card-modern neon-amber">
|
|
||||||
<div className="kpi-icon"><Clock size={20} /></div>
|
|
||||||
<div className="kpi-info">
|
|
||||||
<label>P-ETA Variance</label>
|
|
||||||
<div className="kpi-value-wrap">
|
|
||||||
<span className="value">-1.2m</span>
|
|
||||||
<span className="trend positive">IMPROVED</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Main Traffic & Triage */}
|
|
||||||
<div className="analytics-body-layout">
|
|
||||||
<div className="intel-card large">
|
|
||||||
<div className="card-top">
|
|
||||||
<div className="card-title">
|
|
||||||
<TrendingUp size={16} />
|
|
||||||
<span>Patient Inflow / Capacity Overlap</span>
|
|
||||||
</div>
|
|
||||||
<div className="legend-mini">
|
|
||||||
<span className="dot current" /> Live Intake
|
|
||||||
<span className="dot base" /> Expected
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="viewport-chart">
|
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
|
||||||
<AreaChart data={volumeData}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorIntake" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="var(--accent-cyan)" stopOpacity={0.1}/>
|
|
||||||
<stop offset="95%" stopColor="var(--accent-cyan)" stopOpacity={0}/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsla(215, 32%, 17%, 0.05)" />
|
|
||||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 10, fontWeight: 750, fill: 'var(--text-muted)' }} />
|
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fontWeight: 750, fill: 'var(--text-muted)' }} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: 'var(--shadow-premium)', background: '#fff' }}
|
|
||||||
/>
|
|
||||||
<Area type="monotone" dataKey="count" stroke="var(--accent-cyan)" strokeWidth={3} fillOpacity={1} fill="url(#colorIntake)" />
|
|
||||||
<Line type="monotone" dataKey="baseline" stroke="var(--text-muted)" strokeDasharray="5 5" strokeWidth={1} dot={false} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="intel-card side">
|
|
||||||
<div className="card-top">
|
|
||||||
<div className="card-title">
|
|
||||||
<PieChart size={16} />
|
|
||||||
<span>Urgency Index</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="viewport-chart donut">
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<RePieChart>
|
|
||||||
<Pie
|
|
||||||
data={triageData}
|
|
||||||
innerRadius={65}
|
|
||||||
outerRadius={85}
|
|
||||||
paddingAngle={8}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{triageData.map((e, idx) => <Cell key={idx} fill={e.color} stroke="none" />)}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
</RePieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div className="donut-center-label">
|
|
||||||
<span className="v">126</span>
|
|
||||||
<span className="l">TOTAL</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pie-legend-v2">
|
|
||||||
{triageData.map(t => (
|
|
||||||
<div key={t.name} className="legend-row">
|
|
||||||
<div className="l-side">
|
|
||||||
<div className="l-dot" style={{ background: t.color }} />
|
|
||||||
<span className="l-name">{t.name.split(' ')[0]}</span>
|
|
||||||
</div>
|
|
||||||
<span className="l-val">{t.value}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 3: Dept Load & Alerts */}
|
|
||||||
<div className="intelligence-footer-grid">
|
|
||||||
<div className="intel-card medium no-bg">
|
|
||||||
<div className="card-title small">DEPARTMENTAL LOAD (HEATMAP)</div>
|
|
||||||
<div className="dept-load-stack">
|
|
||||||
{departmentEfficiency.map(d => (
|
|
||||||
<div key={d.dept} className="dept-progress-block">
|
|
||||||
<div className="d-info">
|
|
||||||
<span className="d-name">{d.dept}</span>
|
|
||||||
<span className="d-val">{d.load}% Load</span>
|
|
||||||
</div>
|
|
||||||
<div className="d-bar-bg">
|
|
||||||
<div className="d-bar-fill" style={{ width: `${d.load}%`, background: d.load > 85 ? 'var(--alert-red)' : 'var(--accent-cyan)' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="intel-card medium glass-alert">
|
|
||||||
<div className="card-title small alert-mode"><Activity size={14} /> LIVE ANOMALY DETECTION</div>
|
|
||||||
<div className="anomaly-list">
|
|
||||||
<div className="anomaly-item">
|
|
||||||
<span className="time">14:02</span>
|
|
||||||
<p>Suboptimal triage time detected in ER South Cluster.</p>
|
|
||||||
</div>
|
|
||||||
<div className="anomaly-item warning">
|
|
||||||
<span className="time">13:45</span>
|
|
||||||
<p>TeleLink latency spike (+45ms) reported in Hub 4.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
Building2,
|
|
||||||
Users,
|
|
||||||
ChevronRight,
|
|
||||||
AlertTriangle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface HospitalSelectorProps {
|
|
||||||
hospitals: any[];
|
|
||||||
isLoading: boolean;
|
|
||||||
onSelect: (hospital: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HospitalSelector: React.FC<HospitalSelectorProps> = ({
|
|
||||||
hospitals,
|
|
||||||
isLoading,
|
|
||||||
onSelect,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="hospital-selection-overlay">
|
|
||||||
<div className="selection-background">
|
|
||||||
<div className="glow-sphere top-left" />
|
|
||||||
<div className="glow-sphere bottom-right" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="selection-content"
|
|
||||||
>
|
|
||||||
<div className="selection-header">
|
|
||||||
<div className="brand-logo large">
|
|
||||||
<Activity size={48} className="pulse-aura" />
|
|
||||||
CURESELECT{' '}
|
|
||||||
<span style={{ fontWeight: 400, color: 'var(--text-secondary)' }}>
|
|
||||||
EMS
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h1>Command Node Protocol</h1>
|
|
||||||
<p className="selection-subtitle">
|
|
||||||
Regional Emergency Service Network · Authorized Node Access Only
|
|
||||||
</p>
|
|
||||||
<div className="security-scan-line" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hospital-grid">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="loading-hospitals">
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ repeat: Infinity, duration: 1, ease: 'linear' }}
|
|
||||||
>
|
|
||||||
<Activity size={48} color="var(--accent-cyan)" />
|
|
||||||
</motion.div>
|
|
||||||
<p style={{ letterSpacing: '0.2em', fontWeight: 800 }}>
|
|
||||||
ESTABLISHING SECURE UPLINK...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : hospitals.length === 0 ? (
|
|
||||||
<div className="no-hospitals">
|
|
||||||
<AlertTriangle size={48} color="var(--alert-amber)" />
|
|
||||||
<p>
|
|
||||||
No active hospital nodes detected within the regional network grid.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
className="select-hospital-btn"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
>
|
|
||||||
RESCAN NETWORK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
hospitals.map((h, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={h.id}
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 30 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
transition={{
|
|
||||||
delay: idx * 0.08,
|
|
||||||
duration: 0.6,
|
|
||||||
ease: [0.16, 1, 0.3, 1]
|
|
||||||
}}
|
|
||||||
whileHover={{ y: -8, boxShadow: '0 20px 40px hsla(215, 32%, 17%, 0.12)' }}
|
|
||||||
className="hospital-select-card-premium"
|
|
||||||
onClick={() => onSelect(h)}
|
|
||||||
>
|
|
||||||
<div className="card-top-accent" />
|
|
||||||
<div className="h-icon-cluster">
|
|
||||||
<div className="h-icon-orb">
|
|
||||||
<Building2 size={32} />
|
|
||||||
</div>
|
|
||||||
<div className="h-status-indicator pulse-slow" data-status={h.status.toLowerCase().replace(' ', '-')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-card-content">
|
|
||||||
<span className="h-type-tag">{h.type.toUpperCase()} NODE</span>
|
|
||||||
<h3 className="h-name-premium">{h.name}</h3>
|
|
||||||
|
|
||||||
<div className="h-meta-grid">
|
|
||||||
<div className="h-meta-item">
|
|
||||||
<Users size={14} />
|
|
||||||
<span>{h.admin || 'Dr. Admin'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-meta-item">
|
|
||||||
<Activity size={14} />
|
|
||||||
<span>{h.status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="capacity-section">
|
|
||||||
<div className="capacity-header">
|
|
||||||
<span>LOAD CAPACITY</span>
|
|
||||||
<span className="capacity-val">{h.beds}</span>
|
|
||||||
</div>
|
|
||||||
<div className="capacity-track">
|
|
||||||
<motion.div
|
|
||||||
className="capacity-fill"
|
|
||||||
initial={{ width: 0 }}
|
|
||||||
animate={{
|
|
||||||
width: (parseInt(h.beds) / parseInt(h.beds.split('/')[1] || '100')) * 100 + '%'
|
|
||||||
}}
|
|
||||||
transition={{ delay: 0.5 + idx * 0.1, duration: 1 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="h-access-btn">
|
|
||||||
INITIALIZE SESSION <ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Database,
|
|
||||||
Search,
|
|
||||||
Calendar,
|
|
||||||
TrendingUp,
|
|
||||||
Clock,
|
|
||||||
FileText,
|
|
||||||
User,
|
|
||||||
Activity,
|
|
||||||
MapPin,
|
|
||||||
ChevronRight,
|
|
||||||
Filter,
|
|
||||||
CheckCircle2,
|
|
||||||
Video
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export const PatientArchive: React.FC = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState<'LIST' | 'TIMELINE'>('LIST');
|
|
||||||
|
|
||||||
const archive = [
|
|
||||||
{ id: 'INC-9012', date: '2026-05-04', name: 'Rajit Bose', age: 52, gender: 'M', triage: 'RED', category: 'CARDIAC', outcome: 'DISCHARGED', ambulance: 'ALS-02', teleLink: true },
|
|
||||||
{ id: 'INC-8955', date: '2026-05-03', name: 'Sana Khan', age: 24, gender: 'F', triage: 'YELLOW', category: 'TRAUMA', outcome: 'ADMITTED', ambulance: 'BLS-09', teleLink: false },
|
|
||||||
{ id: 'INC-8940', date: '2026-05-03', name: 'Unknown Male', age: 0, gender: 'M', triage: 'RED', category: 'STROKE', outcome: 'DECEASED', ambulance: 'ALS-01', teleLink: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="module-content"
|
|
||||||
>
|
|
||||||
<div className="module-header-modern">
|
|
||||||
<div className="title-wrap">
|
|
||||||
<h3>PATIENT & INCIDENT HISTORY</h3>
|
|
||||||
<div className="live-pill">
|
|
||||||
<span className="pulse" /> COMPREHENSIVE CARE RECORDS
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="action-area-header">
|
|
||||||
<div className="date-picker-mini">
|
|
||||||
<Calendar size={14} />
|
|
||||||
<span>LAST 30 DAYS</span>
|
|
||||||
</div>
|
|
||||||
<div className="search-mini">
|
|
||||||
<Search size={14} />
|
|
||||||
<input type="text" placeholder="Search by ID, Name, MRN..." />
|
|
||||||
</div>
|
|
||||||
<button className="register-staff-btn-premium">
|
|
||||||
<Filter size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="archive-grid">
|
|
||||||
<div className="archive-list-panel">
|
|
||||||
<table className="staff-table-premium">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Patient ID & Date</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Provider Details</th>
|
|
||||||
<th>Outcome</th>
|
|
||||||
<th>History</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{archive.map(p => (
|
|
||||||
<tr key={p.id} className="archive-row-hover">
|
|
||||||
<td>
|
|
||||||
<div className="patient-main-ident">
|
|
||||||
<strong>{p.name}</strong>
|
|
||||||
<span>{p.id} · {p.date}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="cat-badge-wrap">
|
|
||||||
<span className={`cat-pill cat-${p.category.toLowerCase()}`}>{p.category}</span>
|
|
||||||
<span className={`triage-tag triage-${p.triage.toLowerCase()}`}>{p.triage}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="amb-emt-meta">
|
|
||||||
<span>{p.ambulance}</span>
|
|
||||||
{p.teleLink && <Video size={10} style={{ color: 'var(--accent-cyan)' }} />}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className={`outcome-pill outcome-${p.outcome.toLowerCase()}`}>{p.outcome}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button className="tag-btn"><ChevronRight size={14} /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="archive-metrics-side">
|
|
||||||
<div className="side-metric-card">
|
|
||||||
<h4>Outcome Distribution</h4>
|
|
||||||
<div className="metric-bar-chart">
|
|
||||||
{/* Simple Mock Bars */}
|
|
||||||
<div className="bar-row">
|
|
||||||
<span>Admitted</span>
|
|
||||||
<div className="bar-container"><div className="bar-val" style={{ width: '65%', background: 'var(--accent-cyan)' }} /></div>
|
|
||||||
<span className="perc">65%</span>
|
|
||||||
</div>
|
|
||||||
<div className="bar-row">
|
|
||||||
<span>Discharged</span>
|
|
||||||
<div className="bar-container"><div className="bar-val" style={{ width: '25%', background: 'var(--accent-green)' }} /></div>
|
|
||||||
<span className="perc">25%</span>
|
|
||||||
</div>
|
|
||||||
<div className="bar-row">
|
|
||||||
<span>Referred</span>
|
|
||||||
<div className="bar-container"><div className="bar-val" style={{ width: '10%', background: 'var(--warning-amber)' }} /></div>
|
|
||||||
<span className="perc">10%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="side-metric-card">
|
|
||||||
<h4>Timeline Insight (Scene to ED)</h4>
|
|
||||||
<div className="avg-time-display">
|
|
||||||
<Clock size={24} style={{ color: 'var(--accent-cyan)' }} />
|
|
||||||
<div className="time-val">28.4 <small>min</small></div>
|
|
||||||
<span className="trend down">▼ 2m vs Last Month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Hospital,
|
|
||||||
MapPin,
|
|
||||||
PhoneCall,
|
|
||||||
Mail,
|
|
||||||
Award,
|
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
ShieldCheck,
|
|
||||||
User,
|
|
||||||
ChevronRight,
|
|
||||||
Search,
|
|
||||||
Building2,
|
|
||||||
Trash2,
|
|
||||||
Edit2
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export const ReferralsSetup: React.FC = () => {
|
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
|
||||||
|
|
||||||
const referrals = [
|
|
||||||
{ id: 'REF-01', name: 'Narayana Health City', type: 'Tertiary Care', specialty: 'CARDIAC, ONCOLOGY', contact: 'Dr. Vikram - 9845011223', address: 'Hosur Road, Bangalore', status: 'ACTIVE' },
|
|
||||||
{ id: 'REF-02', name: 'St. Johns Medical College', type: 'Teaching Hospital', specialty: 'TRAUMA, PEDIATRICS', contact: 'Coordinator Ravi - 9845099881', address: 'Koramangala, Bangalore', status: 'ACTIVE' },
|
|
||||||
{ id: 'REF-03', name: 'Nimhans', type: 'Psychiatric/Neuro', specialty: 'NEUROLOGY, NEUROSURGERY', contact: 'ER Helpdesk - 080-26995000', address: 'Hosur Road, Bangalore', status: 'ACTIVE' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
className="module-content"
|
|
||||||
>
|
|
||||||
<div className="module-header-modern">
|
|
||||||
<div className="title-wrap">
|
|
||||||
<h3>REFERRAL HOSPITAL NETWORK</h3>
|
|
||||||
<div className="live-pill">
|
|
||||||
<span className="pulse" /> {referrals.length} TRUSTED PARTNERS
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="action-area-header">
|
|
||||||
<button className="register-staff-btn-premium" onClick={() => setIsAddOpen(true)}>
|
|
||||||
<Plus size={18} />
|
|
||||||
<span>ADD REFERRAL NODE</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="referral-grid-layout">
|
|
||||||
<div className="referral-list-main">
|
|
||||||
{referrals.map(ref => (
|
|
||||||
<div key={ref.id} className="referral-node-card">
|
|
||||||
<div className="node-icon-bubble">
|
|
||||||
<Building2 size={24} />
|
|
||||||
</div>
|
|
||||||
<div className="node-info-content">
|
|
||||||
<div className="node-top-row">
|
|
||||||
<div className="node-title-stack">
|
|
||||||
<h4>{ref.name}</h4>
|
|
||||||
<span className="node-type">{ref.type}</span>
|
|
||||||
</div>
|
|
||||||
<div className="node-badge-active">PARTNERED</div>
|
|
||||||
</div>
|
|
||||||
<div className="node-details-grid">
|
|
||||||
<div className="d-item">
|
|
||||||
<Award size={14} />
|
|
||||||
<span>Specialties: {ref.specialty}</span>
|
|
||||||
</div>
|
|
||||||
<div className="d-item">
|
|
||||||
<MapPin size={14} />
|
|
||||||
<span>{ref.address}</span>
|
|
||||||
</div>
|
|
||||||
<div className="d-item">
|
|
||||||
<PhoneCall size={14} />
|
|
||||||
<span>{ref.contact}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="node-footer-actions">
|
|
||||||
<button className="node-action"><Edit2 size={14} /> Configure Notifications</button>
|
|
||||||
<button className="node-action-danger"><Trash2 size={14} /> Remove Node</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="referral-settings-side">
|
|
||||||
<div className="side-card-premium">
|
|
||||||
<div className="card-header-mini">
|
|
||||||
<Settings size={16} />
|
|
||||||
<h4>Global Referral Preferences</h4>
|
|
||||||
</div>
|
|
||||||
<div className="setting-control-row">
|
|
||||||
<div className="setting-info">
|
|
||||||
<strong>Auto-Pre-Alert</strong>
|
|
||||||
<span>Notify referral hub immediately on booking</span>
|
|
||||||
</div>
|
|
||||||
<input type="checkbox" defaultChecked />
|
|
||||||
</div>
|
|
||||||
<div className="setting-control-row">
|
|
||||||
<div className="setting-info">
|
|
||||||
<strong>Mortuary Referral</strong>
|
|
||||||
<span>Enable deceased-outcome routing logic</span>
|
|
||||||
</div>
|
|
||||||
<input type="checkbox" defaultChecked />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="side-card-premium dark-accent">
|
|
||||||
<ShieldCheck size={32} style={{ opacity: 0.5, marginBottom: '16px' }} />
|
|
||||||
<h4>Protocol Compliance</h4>
|
|
||||||
<p style={{ fontSize: '0.8rem', opacity: 0.8, lineHeight: 1.5 }}>
|
|
||||||
All outgoing referrals are logged and linked to active ePCR data for legal handoff audit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Building2,
|
|
||||||
Users,
|
|
||||||
LayoutDashboard,
|
|
||||||
Stethoscope,
|
|
||||||
Search,
|
|
||||||
Plus,
|
|
||||||
Edit2,
|
|
||||||
Phone,
|
|
||||||
Settings,
|
|
||||||
MoreVertical,
|
|
||||||
Activity
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card } from '../../components/Common';
|
|
||||||
|
|
||||||
interface SetupPanelProps {
|
|
||||||
setupSubTab: string;
|
|
||||||
onSubTabChange: (tab: string) => void;
|
|
||||||
selectedHospital: any;
|
|
||||||
setSelectedHospital: (h: any) => void;
|
|
||||||
departments: any[];
|
|
||||||
setDepartments: (d: any[]) => void;
|
|
||||||
allUsers: any[];
|
|
||||||
onSaveProfile: () => void;
|
|
||||||
onOpenStaffModal: () => void;
|
|
||||||
onEditStaff: (user: any) => void;
|
|
||||||
onOpenDeptModal: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SetupPanel: React.FC<SetupPanelProps> = ({
|
|
||||||
setupSubTab,
|
|
||||||
onSubTabChange,
|
|
||||||
selectedHospital,
|
|
||||||
setSelectedHospital,
|
|
||||||
departments,
|
|
||||||
setDepartments,
|
|
||||||
allUsers,
|
|
||||||
onSaveProfile,
|
|
||||||
onOpenStaffModal,
|
|
||||||
onEditStaff,
|
|
||||||
onOpenDeptModal,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.99 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
className="module-content"
|
|
||||||
>
|
|
||||||
<div className="setup-layout">
|
|
||||||
<aside className="setup-nav">
|
|
||||||
<button
|
|
||||||
className={`setup-nav-item ${
|
|
||||||
setupSubTab === 'PROFILE' ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => onSubTabChange('PROFILE')}
|
|
||||||
>
|
|
||||||
<Building2 size={14} /> <span>Profile</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`setup-nav-item ${
|
|
||||||
setupSubTab === 'USERS' ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => onSubTabChange('USERS')}
|
|
||||||
>
|
|
||||||
<Users size={14} /> <span>Users</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`setup-nav-item ${
|
|
||||||
setupSubTab === 'DEPTS' ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => onSubTabChange('DEPTS')}
|
|
||||||
>
|
|
||||||
<LayoutDashboard size={14} /> <span>Departments</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`setup-nav-item ${
|
|
||||||
setupSubTab === 'BEDS' ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => onSubTabChange('BEDS')}
|
|
||||||
>
|
|
||||||
<Stethoscope size={14} /> <span>Bed Management</span>
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div className="setup-content-modern no-scrollbar">
|
|
||||||
{setupSubTab === 'PROFILE' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
style={{ display: 'block' }}
|
|
||||||
>
|
|
||||||
<Card title="Hospital Profile & Configuration">
|
|
||||||
<div className="setup-form-modern" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
|
||||||
<div className="form-group-premium" style={{ gridColumn: '1 / -1' }}>
|
|
||||||
<label>Institution Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={selectedHospital?.name || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedHospital({
|
|
||||||
...selectedHospital,
|
|
||||||
name: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium" style={{ gridColumn: '1 / -1' }}>
|
|
||||||
<label>Physical Address</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={selectedHospital?.address || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedHospital({
|
|
||||||
...selectedHospital,
|
|
||||||
address: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Emergency Phone</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={selectedHospital?.emergency_phone || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedHospital({
|
|
||||||
...selectedHospital,
|
|
||||||
emergency_phone: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Contact Phone</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={selectedHospital?.contact_phone || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedHospital({
|
|
||||||
...selectedHospital,
|
|
||||||
contact_phone: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group-premium" style={{ gridColumn: '1 / -1' }}>
|
|
||||||
<label>Contact Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={selectedHospital?.contact_email || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedHospital({
|
|
||||||
...selectedHospital,
|
|
||||||
contact_email: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>GPS Latitude</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={selectedHospital?.gps_lat || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedHospital({
|
|
||||||
...selectedHospital,
|
|
||||||
gps_lat: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>GPS Longitude</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={selectedHospital?.gps_lon || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedHospital({
|
|
||||||
...selectedHospital,
|
|
||||||
gps_lon: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group-premium" style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 0' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="nabh_status"
|
|
||||||
checked={selectedHospital?.nabh_status || false}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedHospital({
|
|
||||||
...selectedHospital,
|
|
||||||
nabh_status: e.target.checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
<label htmlFor="nabh_status" style={{ margin: 0, cursor: 'pointer', fontSize: '1rem', fontWeight: 'bold' }}>NABH Accredited Institution</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ gridColumn: '1 / -1', marginTop: '16px' }}>
|
|
||||||
<button
|
|
||||||
className="setup-confirm-btn"
|
|
||||||
onClick={onSaveProfile}
|
|
||||||
>
|
|
||||||
Save Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{setupSubTab === 'USERS' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
>
|
|
||||||
<Card title="Personnel Registry">
|
|
||||||
<div className="setup-actions-bar">
|
|
||||||
<div className="search-mini">
|
|
||||||
<Search size={16} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Filter by name or role..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="register-staff-btn-premium"
|
|
||||||
onClick={onOpenStaffModal}
|
|
||||||
>
|
|
||||||
<Plus size={16} /> Add New Staff
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<table className="staff-table-premium">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Operator Name</th>
|
|
||||||
<th>Designation</th>
|
|
||||||
<th>Node Alignment</th>
|
|
||||||
<th>Auth Status</th>
|
|
||||||
<th style={{ textAlign: 'right' }}>Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{allUsers
|
|
||||||
.filter(
|
|
||||||
(u) =>
|
|
||||||
u.hospitalId ===
|
|
||||||
selectedHospital?.rawUser?.hospitalId ||
|
|
||||||
u.organisationId ===
|
|
||||||
selectedHospital?.rawUser?.organisationId
|
|
||||||
)
|
|
||||||
.map((u, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td style={{ fontWeight: 800 }}>
|
|
||||||
{u.name || u.username}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className="v-status IDLE"
|
|
||||||
style={{ fontSize: '0.6rem' }}
|
|
||||||
>
|
|
||||||
{u.roles[0]?.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ opacity: 0.7 }}>
|
|
||||||
{selectedHospital?.name}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="node-status-text ON">
|
|
||||||
AUTHORIZED
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ textAlign: 'right' }}>
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
onClick={() => onEditStaff(u)}
|
|
||||||
style={{ background: 'transparent', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer' }}
|
|
||||||
title="Edit Staff"
|
|
||||||
>
|
|
||||||
<Edit2 size={16} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{setupSubTab === 'DEPTS' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
>
|
|
||||||
<Card title="Emergency Departments Node Registry">
|
|
||||||
<div className="setup-actions-bar">
|
|
||||||
<div className="search-mini">
|
|
||||||
<Search size={16} />
|
|
||||||
<input type="text" placeholder="Search departments..." />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="register-staff-btn-premium"
|
|
||||||
onClick={onOpenDeptModal}
|
|
||||||
>
|
|
||||||
<Plus size={16} /> ADD DEPARTMENT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="table-responsive-premium">
|
|
||||||
<table className="staff-table-premium">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Department / Clinical Node</th>
|
|
||||||
<th>Leadership</th>
|
|
||||||
<th>Resource Allocation</th>
|
|
||||||
<th>Communication</th>
|
|
||||||
<th>Ops Status</th>
|
|
||||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{departments.map((d, i) => {
|
|
||||||
const occupancy = ((d.occupiedBeds || 0) / (d.totalBedsCapacity || 1)) * 100;
|
|
||||||
const isHighOccupancy = occupancy > 85;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.tr
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.05 }}
|
|
||||||
>
|
|
||||||
<td style={{ fontWeight: 800 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{
|
|
||||||
width: '36px',
|
|
||||||
height: '36px',
|
|
||||||
background: 'var(--accent-cyan-soft)',
|
|
||||||
borderRadius: '10px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'var(--accent-cyan)'
|
|
||||||
}}>
|
|
||||||
<Activity size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.95rem', color: '#1e293b' }}>{d.name}</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Node: {d.id?.substring(0, 8)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<span style={{ fontWeight: 700, fontSize: '0.85rem' }}>{d.headOfDepartment}</span>
|
|
||||||
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>Chief of Medicine</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: '140px' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', fontWeight: 700 }}>
|
|
||||||
<span style={{ color: isHighOccupancy ? '#ef4444' : '#64748b' }}>{d.occupiedBeds}/{d.totalBedsCapacity} Units</span>
|
|
||||||
<span style={{ color: '#94a3b8' }}>{Math.round(occupancy)}%</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: '6px', width: '100%', background: '#f1f5f9', borderRadius: '3px', overflow: 'hidden' }}>
|
|
||||||
<div style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${occupancy}%`,
|
|
||||||
background: isHighOccupancy ? '#ef4444' : 'var(--accent-cyan)',
|
|
||||||
borderRadius: '3px'
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#10b981', fontWeight: 600 }}>{d.availableBeds} Units Available</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#475569', fontSize: '0.85rem' }}>
|
|
||||||
<Phone size={14} style={{ color: '#94a3b8' }} />
|
|
||||||
{d.contactPhone || 'No direct line'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className={`node-status-text ${d.isActive ? 'ON' : 'OFF'}`} style={{ padding: '4px 10px', borderRadius: '20px', fontSize: '0.65rem', display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
|
||||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: d.isActive ? '#10b981' : '#94a3b8' }} />
|
|
||||||
{d.isActive ? 'OPERATIONAL' : 'INACTIVE'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ textAlign: 'right' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
|
||||||
<button
|
|
||||||
className={`toggle-btn-modern ${d.isActive ? 'disable' : 'enable'}`}
|
|
||||||
style={{ padding: '6px 12px', fontSize: '0.65rem', height: '30px' }}
|
|
||||||
onClick={() => {
|
|
||||||
const n = [...departments];
|
|
||||||
n[i].isActive = !n[i].isActive;
|
|
||||||
setDepartments(n);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{d.isActive ? 'OFFLINE' : 'ONLINE'}
|
|
||||||
</button>
|
|
||||||
<button className="btn-icon" style={{ background: '#f8fafc', borderRadius: '8px', padding: '6px' }}>
|
|
||||||
<Settings size={14} color="#64748b" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</motion.tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{setupSubTab === 'BEDS' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
>
|
|
||||||
<Card title="Inventory & Capacity Management">
|
|
||||||
<div className="setup-actions-bar">
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Active Care Units: {departments.length}
|
|
||||||
</p>
|
|
||||||
<button className="register-staff-btn-premium">
|
|
||||||
<Plus size={16} /> ADD UNIT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<table className="staff-table-premium">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Care Unit / Dept</th>
|
|
||||||
<th>Total Capacity</th>
|
|
||||||
<th>Occupied</th>
|
|
||||||
<th>Availability</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{departments.map((b, i) => {
|
|
||||||
const occ = b.occupiedBeds || 0;
|
|
||||||
const tot = b.totalBedsCapacity || 1;
|
|
||||||
return (
|
|
||||||
<tr key={i}>
|
|
||||||
<td style={{ fontWeight: 800 }}>{b.name}</td>
|
|
||||||
<td>{b.totalBedsCapacity} Units</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{occ}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div
|
|
||||||
className="bed-progress"
|
|
||||||
style={{ width: '100px', marginBottom: 0 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="progress-fill"
|
|
||||||
style={{
|
|
||||||
width: `${(occ / tot) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { X, Eye, EyeOff, User, Shield, Briefcase, FileText } from 'lucide-react';
|
|
||||||
|
|
||||||
interface StaffModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
staffFormData: any;
|
|
||||||
setStaffFormData: (data: any) => void;
|
|
||||||
rolesList: any[];
|
|
||||||
showPassword: boolean;
|
|
||||||
setShowPassword: (show: boolean) => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
isEditing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StaffModal: React.FC<StaffModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
staffFormData,
|
|
||||||
setStaffFormData,
|
|
||||||
rolesList,
|
|
||||||
showPassword,
|
|
||||||
setShowPassword,
|
|
||||||
onSubmit,
|
|
||||||
isEditing = false,
|
|
||||||
}) => {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const role = staffFormData.role || 'ED_DOCTOR';
|
|
||||||
|
|
||||||
const renderRoleSpecificFields = () => {
|
|
||||||
switch (role) {
|
|
||||||
case 'ED_DOCTOR':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Specialization</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.specialization || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, specialization: e.target.value })}
|
|
||||||
placeholder="e.g. Emergency Medicine"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>License Number</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.license_number || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, license_number: e.target.value })}
|
|
||||||
placeholder="e.g. MC-998877"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Department</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.department || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, department: e.target.value })}
|
|
||||||
placeholder="e.g. Emergency Operations"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Designation</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.designation || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, designation: e.target.value })}
|
|
||||||
placeholder="e.g. ERCP Specialist"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Shift Schedule</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.shift || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, shift: e.target.value })}
|
|
||||||
placeholder="e.g. Rotational"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'Hospital Coordinator':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Department</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.department || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, department: e.target.value })}
|
|
||||||
placeholder="e.g. Triage Section"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Designation</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.designation || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, designation: e.target.value })}
|
|
||||||
placeholder="e.g. Quick Response Lead"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Shift Schedule</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.shift || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, shift: e.target.value })}
|
|
||||||
placeholder="e.g. DAY"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Languages Spoken (comma separated)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.languages || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, languages: e.target.value })}
|
|
||||||
placeholder="e.g. English, Tamil, Hindi"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'Hospital Nurse':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Employee ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.employee_id || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, employee_id: e.target.value })}
|
|
||||||
placeholder="e.g. MIO-N005"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Organization ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.org_id || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, org_id: e.target.value })}
|
|
||||||
placeholder="Auto-populated if left blank"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Department</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.department || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, department: e.target.value })}
|
|
||||||
placeholder="e.g. Emergency Room (ER)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Designation</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.designation || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, designation: e.target.value })}
|
|
||||||
placeholder="e.g. Staff Nurse (Grade II)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Shift Schedule</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.shift || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, shift: e.target.value })}
|
|
||||||
placeholder="e.g. NIGHT"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Assigned Floor / Ward</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.assigned_floor || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, assigned_floor: e.target.value })}
|
|
||||||
placeholder="e.g. 2nd Floor - Trauma Wing"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Languages Spoken (comma separated)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.languages || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, languages: e.target.value })}
|
|
||||||
placeholder="e.g. English, Tamil"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Generic fallback fields
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Department</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.department || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, department: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Designation</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.designation || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, designation: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Shift Schedule</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.shift || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, shift: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="premium-modal-overlay">
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
||||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
||||||
className="premium-modal-container"
|
|
||||||
style={{ maxWidth: '800px', width: '90%' }} // Made wider for two columns
|
|
||||||
>
|
|
||||||
<div className="modal-header-premium">
|
|
||||||
<h3>{isEditing ? 'Update Personnel' : 'New Staff Registration'}</h3>
|
|
||||||
<button className="modal-close-btn" onClick={onClose}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setup-form-modern" style={{ maxHeight: '65vh', overflowY: 'auto', padding: '24px' }}>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '28px' }}>
|
|
||||||
|
|
||||||
{/* LEFT COLUMN: Identity & Auth */}
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '14px', paddingBottom: '8px', borderBottom: '1px solid var(--card-border)' }}>
|
|
||||||
<User size={16} style={{ color: 'var(--accent-cyan)' }} /> <h4 style={{ margin: 0, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)', fontWeight: 600, letterSpacing: '0.04em' }}>Identity & Access</h4>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Full Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.name}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, name: e.target.value })}
|
|
||||||
placeholder="e.g. Dr. John Smith"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Organizational Role</label>
|
|
||||||
<select
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.role}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, role: e.target.value })}
|
|
||||||
>
|
|
||||||
{rolesList.length > 0 ? (
|
|
||||||
rolesList.map((r: any) => (
|
|
||||||
<option key={r.id} value={r.name}>
|
|
||||||
{r.name}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<option value="ED_DOCTOR">ED_DOCTOR</option>
|
|
||||||
<option value="Hospital Coordinator">Hospital Coordinator</option>
|
|
||||||
<option value="Hospital Nurse">Hospital Nurse</option>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Email Address</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.email}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, email: e.target.value })}
|
|
||||||
placeholder="e.g. jsmith@hospital.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>Phone Number</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.phone}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, phone: e.target.value })}
|
|
||||||
placeholder="e.g. +919876543210"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>System Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="setup-input-premium"
|
|
||||||
value={staffFormData.username}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, username: e.target.value })}
|
|
||||||
placeholder="e.g. jsmith.ed"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group-premium">
|
|
||||||
<label>{isEditing ? 'New Password (leave blank to keep current)' : 'Temporary Password'}</label>
|
|
||||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<input
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
className="setup-input-premium"
|
|
||||||
style={{ width: '100%', paddingRight: '40px' }}
|
|
||||||
value={staffFormData.password || ''}
|
|
||||||
onChange={(e) => setStaffFormData({ ...staffFormData, password: e.target.value })}
|
|
||||||
placeholder={isEditing ? 'Enter new password...' : 'Password'}
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute', right: '14px', top: '50%', transform: 'translateY(-50%)',
|
|
||||||
background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 0, display: 'flex',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT COLUMN: Role Specific Data */}
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '14px', paddingBottom: '8px', borderBottom: '1px solid var(--card-border)' }}>
|
|
||||||
<Briefcase size={16} style={{ color: 'var(--accent-cyan)' }} /> <h4 style={{ margin: 0, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)', fontWeight: 600, letterSpacing: '0.04em' }}>Professional Details</h4>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
{renderRoleSpecificFields()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-footer-premium">
|
|
||||||
<button className="btn-secondary-glass" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="btn-primary-glass" onClick={onSubmit}>
|
|
||||||
{isEditing ? 'Update Personnel' : 'Register Personnel'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Video,
|
|
||||||
VideoOff,
|
|
||||||
Mic,
|
|
||||||
MicOff,
|
|
||||||
PhoneOff,
|
|
||||||
User,
|
|
||||||
Activity,
|
|
||||||
MessageSquare,
|
|
||||||
ClipboardList,
|
|
||||||
AlertCircle,
|
|
||||||
MoreVertical,
|
|
||||||
Shield,
|
|
||||||
Clock,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
MessageCircle
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card } from '../../components/Common';
|
|
||||||
|
|
||||||
export const TeleLinkHub: React.FC = () => {
|
|
||||||
const [activeCall, setActiveCall] = useState<any>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<'CALL' | 'HISTORY'>('CALL');
|
|
||||||
|
|
||||||
const incomingRequests = [
|
|
||||||
{ id: 'TL-101', patient: 'Rajesh Khanna', triage: 'RED', wait: '1:45', complaint: 'Cardiac Arrest', symptoms: 'Ongoing CPR, 2 shocks delivered' },
|
|
||||||
{ id: 'TL-102', patient: 'Unknown Male', triage: 'RED', wait: '0:30', complaint: 'Potential Stroke', symptoms: 'GCS 12, unilateral weakness' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const callHistory = [
|
|
||||||
{ id: 'TL-098', date: '2026-05-04', duration: '12m 45s', patient: 'Amit Shah', outcome: 'Admission Admitted', emt: 'Arjun K.' },
|
|
||||||
{ id: 'TL-095', date: '2026-05-04', duration: '08m 20s', patient: 'Priya Verma', outcome: 'Consult Completed', emt: 'Suman R.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="module-content"
|
|
||||||
>
|
|
||||||
<div className="module-header-modern">
|
|
||||||
<div className="title-wrap">
|
|
||||||
<h3>TELELINK COMMAND CENTER</h3>
|
|
||||||
<div className="live-pill">
|
|
||||||
<span className="pulse" /> ERCP PHYSICIAN CONSOLE
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="tab-switcher-premium">
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'CALL' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('CALL')}
|
|
||||||
>
|
|
||||||
ACTIVE SESSIONS
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'HISTORY' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('HISTORY')}
|
|
||||||
>
|
|
||||||
CONSULT HISTORY
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'CALL' ? (
|
|
||||||
<div className="telelink-main-grid">
|
|
||||||
{/* Call Queue */}
|
|
||||||
<div className="call-queue-panel">
|
|
||||||
<div className="panel-header-mini">
|
|
||||||
<h4>INCOMING REQUESTS</h4>
|
|
||||||
<span className="count-badge">{incomingRequests.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="queue-list">
|
|
||||||
{incomingRequests.map(req => (
|
|
||||||
<div key={req.id} className={`queue-item triage-${req.triage.toLowerCase()}`}>
|
|
||||||
<div className="q-header">
|
|
||||||
<span className="q-id">{req.id}</span>
|
|
||||||
<span className="q-wait"><Clock size={12} /> {req.wait}</span>
|
|
||||||
</div>
|
|
||||||
<div className="q-body">
|
|
||||||
<div className="q-patient">{req.patient}</div>
|
|
||||||
<div className="q-complaint">{req.complaint}</div>
|
|
||||||
</div>
|
|
||||||
<div className="q-actions">
|
|
||||||
<button className="q-btn accept" onClick={() => setActiveCall(req)}>ACCEPT</button>
|
|
||||||
<button className="q-btn decline">DECLINE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Call Surface */}
|
|
||||||
<div className="call-surface-panel-premium">
|
|
||||||
<div className="surface-glass-glow" />
|
|
||||||
|
|
||||||
{activeCall ? (
|
|
||||||
<div className="active-call-grid">
|
|
||||||
<div className="video-workspace">
|
|
||||||
<div className="video-feed-main">
|
|
||||||
<div className="feed-header-overlay">
|
|
||||||
<div className="secure-badge">
|
|
||||||
<Shield size={14} /> 256-BIT ENCRYPTED
|
|
||||||
</div>
|
|
||||||
<div className="call-duration">12:45</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="video-placeholder-premium">
|
|
||||||
<div className="video-pulse-icon">
|
|
||||||
<Video size={48} />
|
|
||||||
</div>
|
|
||||||
<p>INITIALIZING SECURE VIDEO FEED...</p>
|
|
||||||
<div className="patient-id-overlay">
|
|
||||||
<span className="p-triage" data-triage={activeCall.triage.toLowerCase()}>{activeCall.triage}</span>
|
|
||||||
<div className="p-name-stack">
|
|
||||||
<span className="name">{activeCall.patient}</span>
|
|
||||||
<span className="id">CASE: {activeCall.id}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="video-actions-floating">
|
|
||||||
<button className="v-float-btn"><Mic size={20} /></button>
|
|
||||||
<button className="v-float-btn active"><Video size={20} /></button>
|
|
||||||
<button className="v-float-btn end-session" onClick={() => setActiveCall(null)}><PhoneOff size={20} /></button>
|
|
||||||
<div className="v-divider-vertical" />
|
|
||||||
<button className="v-float-btn"><MessageCircle size={20} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="clinical-data-rail">
|
|
||||||
<div className="rail-section">
|
|
||||||
<div className="rail-head">
|
|
||||||
<Activity size={16} /> <h4>LIVE VITALS</h4>
|
|
||||||
</div>
|
|
||||||
<div className="vitals-mini-grid">
|
|
||||||
<div className="v-stat-card">
|
|
||||||
<label>HEART RATE</label>
|
|
||||||
<div className="val">102<small>BPM</small></div>
|
|
||||||
</div>
|
|
||||||
<div className="v-stat-card">
|
|
||||||
<label>SPO2</label>
|
|
||||||
<div className="val">94%</div>
|
|
||||||
</div>
|
|
||||||
<div className="v-stat-card">
|
|
||||||
<label>BP</label>
|
|
||||||
<div className="val">130/85</div>
|
|
||||||
</div>
|
|
||||||
<div className="v-stat-card warning">
|
|
||||||
<label>TEMP</label>
|
|
||||||
<div className="val">101.4<small>°F</small></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rail-section grow">
|
|
||||||
<div className="rail-head">
|
|
||||||
<ClipboardList size={16} /> <h4>TELE-CONSULT NOTES</h4>
|
|
||||||
</div>
|
|
||||||
<textarea className="clinical-input" placeholder="Type clinical advice, prescriptions, or observations..." />
|
|
||||||
<button className="commit-btn">COMMIT TO EPCR</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="idle-surface-placeholder">
|
|
||||||
<div className="idle-icon-wrap">
|
|
||||||
<VideoOff size={64} />
|
|
||||||
<div className="idle-ring" />
|
|
||||||
</div>
|
|
||||||
<h3>READY FOR UPLINK</h3>
|
|
||||||
<p>Select a pending triage request from the queue to bridge TeleLink session</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="history-panel-full">
|
|
||||||
<table className="staff-table-premium">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Session ID & Date</th>
|
|
||||||
<th>Patient</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
<th>EMT / Field Provider</th>
|
|
||||||
<th>Clinical Outcome</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{callHistory.map(h => (
|
|
||||||
<tr key={h.id}>
|
|
||||||
<td>
|
|
||||||
<div style={{ fontWeight: 700 }}>{h.id}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{h.date}</div>
|
|
||||||
</td>
|
|
||||||
<td>{h.patient}</td>
|
|
||||||
<td>{h.duration}</td>
|
|
||||||
<td>{h.emt}</td>
|
|
||||||
<td>
|
|
||||||
<span className="status-badge" style={{ background: 'rgba(16, 185, 129, 0.1)', color: 'var(--accent-green)' }}>
|
|
||||||
{h.outcome}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button className="tag-btn"><ClipboardList size={14} /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
Plus,
|
|
||||||
TrendingUp,
|
|
||||||
Clock,
|
|
||||||
MapPin,
|
|
||||||
Navigation,
|
|
||||||
Truck,
|
|
||||||
Video,
|
|
||||||
FileText,
|
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface TripManagementProps {
|
|
||||||
tripsData: any[];
|
|
||||||
onDeleteTrip: (id: string) => void;
|
|
||||||
onOpenBooking: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TripManagement: React.FC<TripManagementProps> = ({
|
|
||||||
tripsData,
|
|
||||||
onDeleteTrip,
|
|
||||||
onOpenBooking,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
className="module-content"
|
|
||||||
>
|
|
||||||
<div className="module-header-modern">
|
|
||||||
<div className="title-wrap">
|
|
||||||
<h3>LIVE TRIP MANAGEMENT</h3>
|
|
||||||
<div className="live-pill">
|
|
||||||
<span className="pulse" /> 8 ACTIVE TRANSPORTS
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="action-area-header">
|
|
||||||
<div className="search-mini">
|
|
||||||
<Search size={14} />
|
|
||||||
<input type="text" placeholder="Search Patient, MRN..." />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onOpenBooking}
|
|
||||||
className="register-staff-btn-premium"
|
|
||||||
>
|
|
||||||
<Plus size={18} />{' '}
|
|
||||||
<span className="hide-mobile">NEW DISPATCH</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-strip stats-grid-responsive">
|
|
||||||
<div className="stat-card-premium">
|
|
||||||
<span className="stat-label">Active Trips</span>
|
|
||||||
<div className="stat-value">{tripsData.length}</div>
|
|
||||||
<div className="stat-trend up">
|
|
||||||
<TrendingUp size={12} /> 25% vs last hour
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card-premium">
|
|
||||||
<span className="stat-label">Avg Arrival E.T.A</span>
|
|
||||||
<div className="stat-value">9.4m</div>
|
|
||||||
<div className="stat-trend down">
|
|
||||||
<Clock size={12} /> 10% vs last hour
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card-premium">
|
|
||||||
<span className="stat-label">I.F.T Pending Nodes</span>
|
|
||||||
<div className="stat-value">03</div>
|
|
||||||
<div
|
|
||||||
className="stat-trend"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Status: Operational
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="trip-mgmt-table-container">
|
|
||||||
<table className="staff-table-premium">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Accession & Patient</th>
|
|
||||||
<th>Trip Parameters</th>
|
|
||||||
<th>Fleet Asset</th>
|
|
||||||
<th>Live Progress Timeline</th>
|
|
||||||
<th>Operational Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tripsData.map((t) => (
|
|
||||||
<tr key={t.id}>
|
|
||||||
<td>
|
|
||||||
<div className="trip-patient-ident">
|
|
||||||
<div className="trip-patient-main">{t.patient}</div>
|
|
||||||
<div className="trip-id-mono">
|
|
||||||
{t.id} · MRNID: {t.mrn}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div
|
|
||||||
className="route-cell"
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="r-point">
|
|
||||||
<MapPin size={12} /> {t.origin}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="route-line"
|
|
||||||
style={{
|
|
||||||
height: '20px',
|
|
||||||
borderLeft: '2px dashed var(--card-border)',
|
|
||||||
margin: '4px 5px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="r-point">
|
|
||||||
<Navigation size={12} /> {t.destination}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="vehicle-crew-stack">
|
|
||||||
<div
|
|
||||||
className="vc-unit"
|
|
||||||
style={{
|
|
||||||
fontWeight: 900,
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Truck size={14} /> {t.vehicle}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="vc-crew"
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.crew}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="progress-cell" style={{ width: '240px' }}>
|
|
||||||
<div
|
|
||||||
className="eta-container"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`v-status ${
|
|
||||||
t.status.replace(' ', '') === 'ARRIVED'
|
|
||||||
? 'RUNNING'
|
|
||||||
: 'IDLE'
|
|
||||||
}`}
|
|
||||||
style={{ fontSize: '0.65rem' }}
|
|
||||||
>
|
|
||||||
{t.status}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.eta} REMAINING
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="trip-status-timeline">
|
|
||||||
<div
|
|
||||||
className={`ts-step ${t.step >= 1 ? 'completed' : ''}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`ts-line ${t.step >= 2 ? 'active' : ''}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`ts-step ${
|
|
||||||
t.step >= 2
|
|
||||||
? 'completed'
|
|
||||||
: t.step === 1
|
|
||||||
? 'active'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`ts-line ${t.step >= 3 ? 'active' : ''}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`ts-step ${
|
|
||||||
t.step >= 3
|
|
||||||
? 'completed'
|
|
||||||
: t.step === 2
|
|
||||||
? 'active'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div
|
|
||||||
className="trip-action-group"
|
|
||||||
style={{ display: 'flex', gap: '8px' }}
|
|
||||||
>
|
|
||||||
<button className="tag-btn">
|
|
||||||
<Video size={14} />
|
|
||||||
</button>
|
|
||||||
<button className="tag-btn">
|
|
||||||
<FileText size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="tag-btn"
|
|
||||||
onClick={() => onDeleteTrip(t.id)}
|
|
||||||
style={{ color: 'var(--alert-red)' }}
|
|
||||||
>
|
|
||||||
<XCircle size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user