implement TeleEMS platform architecture with centralized API client and master data management system
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>teleems</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3247
package-lock.json
generated
Normal file
3247
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "teleems",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"framer-motion": "^12.38.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
BIN
public/console_bg.png
Normal file
BIN
public/console_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 755 KiB |
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
25
scratch/check_tags.js
Normal file
25
scratch/check_tags.js
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
const content = fs.readFileSync('src/pages/FleetDispatch.tsx', 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let divStack = 0;
|
||||
let headerStack = 0;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const lineNum = index + 1;
|
||||
const openingDivs = (line.match(/<div(?![^>]*\/>)/g) || []).length;
|
||||
const closingDivs = (line.match(/<\/div>/g) || []).length;
|
||||
|
||||
divStack += openingDivs - closingDivs;
|
||||
|
||||
if (line.includes('case \'')) {
|
||||
console.log(`At Line ${lineNum} (${line.trim()}): divStack=${divStack}`);
|
||||
}
|
||||
|
||||
if (lineNum % 100 === 0) {
|
||||
// console.log(`At Line ${lineNum}: divStack=${divStack}`);
|
||||
}
|
||||
});
|
||||
console.log(`Final stacks: divStack=${divStack}`);
|
||||
184
src/App.css
Normal file
184
src/App.css
Normal file
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
244
src/App.tsx
Normal file
244
src/App.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { TopBar } from './components/TopBar';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { LiveIncidents } from './pages/LiveIncidents';
|
||||
import { FleetDispatch } from './pages/FleetDispatch';
|
||||
import { PatientClinical } from './pages/PatientClinical';
|
||||
import { HospitalsNetwork } from './pages/HospitalsNetwork';
|
||||
import { AnalyticsReports } from './pages/AnalyticsReports';
|
||||
import { UserManagement } from './pages/UserManagement';
|
||||
import { PlatformConfig } from './pages/PlatformConfig';
|
||||
import { AuditCompliance } from './pages/AuditCompliance';
|
||||
import { SystemHealth } from './pages/SystemHealth';
|
||||
import { HospitalConsole } from './pages/HospitalConsole';
|
||||
import { MasterDataManagement } from './pages/MasterData';
|
||||
import { CallerPortal } from './pages/CallerPortal';
|
||||
import { Login } from './pages/Login';
|
||||
import { FleetLogin } from './pages/FleetLogin';
|
||||
import { FleetOperatorDashboard } from './pages/FleetOperatorDashboard';
|
||||
import { PerspectiveLauncher } from './pages/PerspectiveLauncher';
|
||||
import { RoleLogin } from './pages/RoleLogin';
|
||||
import { ComingSoonPortal } from './pages/ComingSoonPortal';
|
||||
import {
|
||||
Building2,
|
||||
Stethoscope,
|
||||
Activity,
|
||||
User,
|
||||
Scan,
|
||||
ShoppingCart
|
||||
} from 'lucide-react';
|
||||
|
||||
import { isTokenExpired, logout } from './utils/auth';
|
||||
|
||||
// --- ROLE-BASED ACCESS CONTROL ---
|
||||
const RoleProtectedRoute: React.FC<{
|
||||
children: React.ReactNode,
|
||||
allowedRoles: string[],
|
||||
user: any
|
||||
}> = ({ children, allowedRoles, user }) => {
|
||||
const isAuthenticated = localStorage.getItem('teleems_auth') === 'true';
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
|
||||
const userRoles = Array.isArray(user?.roles) ? user.roles : [];
|
||||
const hasAccess = allowedRoles.some(role => userRoles.includes(role)) || userRoles.includes('CURESELECT_ADMIN');
|
||||
|
||||
if (!hasAccess) {
|
||||
// Redirect to their respective "home" if they don't have access
|
||||
if (userRoles.includes('FLEET_OPERATOR')) return <Navigate to="/fleet-operator" replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
function AppContent() {
|
||||
const location = useLocation();
|
||||
|
||||
// --- SESSION MONITORING ---
|
||||
// Periodically check if the token has expired
|
||||
useEffect(() => {
|
||||
const checkSession = () => {
|
||||
const token = localStorage.getItem('teleems_token');
|
||||
const auth = localStorage.getItem('teleems_auth') === 'true';
|
||||
|
||||
if (auth && token && isTokenExpired(token)) {
|
||||
console.warn('Session expired. Logging out...');
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
// Check on mount
|
||||
checkSession();
|
||||
|
||||
// Check on every route change
|
||||
checkSession();
|
||||
|
||||
// Periodically check every 30 seconds
|
||||
const interval = setInterval(checkSession, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [location.pathname]);
|
||||
|
||||
// --- DEVELOPMENT BYPASS ---
|
||||
// In a real production app, this would be removed.
|
||||
// For the user's request: "this admin so don't want login give me admin level access"
|
||||
/* Commented out to allow testing of launcher and login flow
|
||||
useEffect(() => {
|
||||
const isAuth = localStorage.getItem('teleems_auth') === 'true';
|
||||
const isCaller = window.location.pathname === '/caller';
|
||||
const isLogin = window.location.pathname === '/login';
|
||||
|
||||
if (!isAuth && !isCaller && !isLogin) {
|
||||
console.log('Dev Mode: Auto-authenticating as CureSelect Super Admin');
|
||||
localStorage.setItem('teleems_auth', 'true');
|
||||
localStorage.setItem('teleems_token', 'dev-super-token-2026');
|
||||
localStorage.setItem('teleems_user', JSON.stringify({
|
||||
id: 'admin-001',
|
||||
username: 'CureSelect Super Admin',
|
||||
roles: ['CURESELECT_ADMIN', 'ADMIN'],
|
||||
metadata: {
|
||||
organization: { company_name: 'CureSelect Healthcare LLP' }
|
||||
}
|
||||
}));
|
||||
// Force reload to update navigation
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
*/
|
||||
|
||||
const isLoginPage = location.pathname.startsWith('/login') || location.pathname === '/fleet-login' || location.pathname === '/launcher';
|
||||
|
||||
const isAuthenticated = localStorage.getItem('teleems_auth') === 'true';
|
||||
const user = JSON.parse(localStorage.getItem('teleems_user') || '{}');
|
||||
|
||||
// --- PUBLIC ROUTES (No Auth Required) ---
|
||||
if (isLoginPage || (location.pathname === '/' && !isAuthenticated)) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<PerspectiveLauncher />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/login/:role" element={<RoleLogin />} />
|
||||
<Route path="/fleet-login" element={<FleetLogin />} />
|
||||
<Route path="/launcher" element={<PerspectiveLauncher />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// --- PROTECTED ROUTES (Auth Required) ---
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<ErrorBoundary>
|
||||
<Sidebar />
|
||||
</ErrorBoundary>
|
||||
<main className="main-content">
|
||||
<div className="scanline" />
|
||||
<TopBar />
|
||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
isAuthenticated ? (
|
||||
user?.roles?.includes('FLEET_OPERATOR') && !user?.roles?.includes('CURESELECT_ADMIN')
|
||||
? <Navigate to="/fleet-operator" replace />
|
||||
: <Dashboard />
|
||||
) : (
|
||||
<Navigate to="/launcher" replace />
|
||||
)
|
||||
} />
|
||||
<Route path="/incidents" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN', 'PILOT', 'COORDINATOR']} user={user}>
|
||||
<LiveIncidents />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/fleet" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN', 'STATION_INCHARGE']} user={user}>
|
||||
<FleetDispatch />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/clinical" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'EMT']} user={user}>
|
||||
<PatientClinical />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/hospitals" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN']} user={user}>
|
||||
<HospitalsNetwork />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/analytics" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'STATION_INCHARGE']} user={user}>
|
||||
<AnalyticsReports />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/users" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN']} user={user}>
|
||||
<UserManagement />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/config" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN']} user={user}>
|
||||
<PlatformConfig />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/compliance" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN', 'HOSPITAL_ADMIN']} user={user}>
|
||||
<AuditCompliance />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/health" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN']} user={user}>
|
||||
<SystemHealth />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/hospital-console" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT']} user={user}>
|
||||
<HospitalConsole />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/master-data" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN']} user={user}>
|
||||
<MasterDataManagement />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
<Route path="/caller" element={<CallerPortal />} />
|
||||
<Route path="/fleet-operator" element={
|
||||
<RoleProtectedRoute allowedRoles={['CURESELECT_ADMIN', 'FLEET_OPERATOR']} user={user}>
|
||||
<FleetOperatorDashboard />
|
||||
</RoleProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* --- NEW PERSPECTIVE PORTALS --- */}
|
||||
<Route path="/launcher" element={<PerspectiveLauncher />} />
|
||||
<Route path="/hospital-group" element={<ComingSoonPortal title="Hospital Group" icon={Building2} />} />
|
||||
<Route path="/provider" element={<ComingSoonPortal title="Provider" icon={Stethoscope} />} />
|
||||
<Route path="/provider-react" element={<ComingSoonPortal title="Provider React" icon={Activity} />} />
|
||||
<Route path="/patient-portal" element={<ComingSoonPortal title="Patient" icon={User} />} />
|
||||
<Route path="/scan-centre" element={<ComingSoonPortal title="Scan Centre" icon={Scan} />} />
|
||||
<Route path="/cart" element={<ComingSoonPortal title="Cart / Mobile" icon={ShoppingCart} />} />
|
||||
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
134
src/api/apiClient.ts
Normal file
134
src/api/apiClient.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { logout } from '../utils/auth';
|
||||
|
||||
const BASE_URL = 'https://teleems-api-gateway.onrender.com';
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized API client that handles automatic token injection
|
||||
* and global error handling (like 401 Unauthorized)
|
||||
*/
|
||||
export const apiClient = {
|
||||
request: async (endpoint: string, options: RequestOptions = {}) => {
|
||||
const { token, headers, ...rest } = options;
|
||||
|
||||
// Use provided token or get from localStorage
|
||||
const authToken = token || localStorage.getItem('teleems_token');
|
||||
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (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}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { ...defaultHeaders, ...headers },
|
||||
...rest,
|
||||
});
|
||||
|
||||
// Handle session expiration
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.warn('Unauthorized request detected. Triggering auto-logout...');
|
||||
logout();
|
||||
return null; // Return null as the app will redirect
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { ...data, status: response.status };
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API Request Error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
get: (endpoint: string, options: RequestOptions = {}) =>
|
||||
apiClient.request(endpoint, { ...options, method: 'GET' }),
|
||||
|
||||
post: (endpoint: string, body: any, options: RequestOptions = {}) =>
|
||||
apiClient.request(endpoint, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
|
||||
put: (endpoint: string, body: any, options: RequestOptions = {}) =>
|
||||
apiClient.request(endpoint, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
|
||||
patch: (endpoint: string, body: any, options: RequestOptions = {}) =>
|
||||
apiClient.request(endpoint, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
|
||||
delete: (endpoint: string, options: RequestOptions = {}) =>
|
||||
apiClient.request(endpoint, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
55
src/api/auth.ts
Normal file
55
src/api/auth.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { apiClient } from './apiClient';
|
||||
import type { AuthUser, LoginResponse, MfaSetupResponse, MfaVerifyResponse } from './types';
|
||||
|
||||
export const authApi = {
|
||||
login: async (username: string, password: string): Promise<LoginResponse> => {
|
||||
return apiClient.post('/v1/auth/login', { username, password });
|
||||
},
|
||||
|
||||
verifyMfa: async (mfaSessionToken: string, totpCode: string): Promise<LoginResponse> => {
|
||||
return apiClient.post('/v1/auth/mfa/verify', {
|
||||
mfa_session_token: mfaSessionToken,
|
||||
totp_code: totpCode
|
||||
});
|
||||
},
|
||||
|
||||
setupMfa: async (token: string): Promise<MfaSetupResponse> => {
|
||||
return apiClient.post('/v1/auth/mfa/totp/setup', {}, { token });
|
||||
},
|
||||
|
||||
verifyTotpSetup: async (token: string, totpCode: string): Promise<MfaVerifyResponse> => {
|
||||
return apiClient.post('/v1/auth/mfa/totp/verify', { totp_code: totpCode }, { token });
|
||||
},
|
||||
|
||||
getAuditLogs: async (token: string, limit = 20, offset = 0) => {
|
||||
return apiClient.get(`/v1/auth/audit-logs?limit=${limit}&offset=${offset}`, { token });
|
||||
},
|
||||
|
||||
registerUser: async (userData: any, token: string) => {
|
||||
return apiClient.post('/v1/auth/users', userData, { token });
|
||||
},
|
||||
|
||||
getUsers: async (token: string) => {
|
||||
return apiClient.get('/v1/auth/users', { token });
|
||||
},
|
||||
|
||||
updateUser: async (userId: string, userData: any, token: string) => {
|
||||
return apiClient.put(`/v1/auth/users/${userId}`, userData, { token });
|
||||
},
|
||||
|
||||
disableMfa: async (password: string, token: string) => {
|
||||
return apiClient.post('/v1/auth/mfa/disable', { password }, { token });
|
||||
},
|
||||
|
||||
getDepartments: async (token: string) => {
|
||||
return apiClient.get('/v1/hospital/departments', { token });
|
||||
},
|
||||
|
||||
createDepartment: async (deptData: any, token: string) => {
|
||||
return apiClient.post('/v1/hospital/departments', deptData, { token });
|
||||
},
|
||||
|
||||
getRoles: async (token: string) => {
|
||||
return apiClient.get('/v1/auth/roles', { token });
|
||||
}
|
||||
};
|
||||
47
src/api/fleet.ts
Normal file
47
src/api/fleet.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { apiClient } from './apiClient';
|
||||
|
||||
export const fleetApi = {
|
||||
createStation: async (stationData: any, token: string) => {
|
||||
return apiClient.post('/v1/fleet/stations', stationData, { token });
|
||||
},
|
||||
|
||||
getStations: async (token: string, organisationId?: string) => {
|
||||
const url = organisationId
|
||||
? `/v1/fleet/stations?organisationId=${organisationId}`
|
||||
: `/v1/fleet/stations`;
|
||||
|
||||
return apiClient.get(url, { token });
|
||||
},
|
||||
|
||||
createVehicle: async (vehicleData: any, token: string) => {
|
||||
return apiClient.post('/v1/fleet/vehicles', vehicleData, { token });
|
||||
},
|
||||
|
||||
getVehicles: async (token: string, orgId: string) => {
|
||||
return apiClient.get(`/v1/fleet/vehicles?org_id=${orgId}`, { token });
|
||||
},
|
||||
|
||||
updateVehicleDetails: async (vehicleId: string, vehicleData: any, token: string) => {
|
||||
return apiClient.patch(`/v1/fleet/vehicles/${vehicleId}`, vehicleData, { token });
|
||||
},
|
||||
|
||||
createStaff: async (staffData: any, token: string) => {
|
||||
return apiClient.post('/v1/fleet/staff', staffData, { token });
|
||||
},
|
||||
|
||||
getStaff: async (token: string, orgId: string) => {
|
||||
return apiClient.get(`/v1/fleet/staff?organisationId=${orgId}`, { token });
|
||||
},
|
||||
|
||||
createRoster: async (rosterData: any, token: string) => {
|
||||
return apiClient.post('/v1/fleet/roster', rosterData, { token });
|
||||
},
|
||||
|
||||
startShift: async (shiftData: any, token: string) => {
|
||||
return apiClient.post('/v1/fleet/shifts/start', shiftData, { token });
|
||||
},
|
||||
|
||||
getInventoryMaster: async (token: string) => {
|
||||
return apiClient.get('/v1/fleet/inventory/master', { token });
|
||||
}
|
||||
};
|
||||
45
src/api/incidents.ts
Normal file
45
src/api/incidents.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { apiClient } from './apiClient';
|
||||
import type { ApiResponse, Incident } from './types';
|
||||
|
||||
export const incidentsApi = {
|
||||
createIncident: async (incidentData: any, token: string): Promise<ApiResponse<Incident>> => {
|
||||
return apiClient.post('/v1/incidents', incidentData, { token });
|
||||
},
|
||||
|
||||
getIncidents: async (params: { status?: string; limit?: number; date_from?: string }, token: string): Promise<ApiResponse<Incident[]>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.status) query.append('status', params.status);
|
||||
if (params.limit) query.append('limit', params.limit.toString());
|
||||
if (params.date_from) query.append('date_from', params.date_from);
|
||||
|
||||
return apiClient.get(`/v1/incidents?${query.toString()}`, { token });
|
||||
},
|
||||
|
||||
getIncidentById: async (id: string, token: string): Promise<ApiResponse<Incident>> => {
|
||||
return apiClient.get(`/v1/incidents/${id}`, { token });
|
||||
},
|
||||
|
||||
updateIncident: async (id: string, incidentData: any, token: string): Promise<ApiResponse<Incident>> => {
|
||||
return apiClient.patch(`/v1/incidents/${id}`, incidentData, { token });
|
||||
},
|
||||
|
||||
getIncidentTimeline: async (id: string, token: string): Promise<ApiResponse<any[]>> => {
|
||||
return apiClient.get(`/v1/incidents/${id}/timeline`, { token });
|
||||
},
|
||||
|
||||
getIncidentAudit: async (id: string, token: string): Promise<ApiResponse<any[]>> => {
|
||||
return apiClient.get(`/v1/incidents/${id}/audit`, { token });
|
||||
},
|
||||
|
||||
recommendDispatch: async (payload: { gps_lat: number, gps_lon: number, vehicle_type_required: string }, token: string): Promise<ApiResponse<any>> => {
|
||||
return apiClient.post('/v1/dispatch/recommend', payload, { token });
|
||||
},
|
||||
|
||||
dispatchVehicle: async (incidentId: string, vehicleId: string, token: string): Promise<ApiResponse<any>> => {
|
||||
return apiClient.post(`/v1/incidents/${incidentId}/dispatch`, { vehicle_id: vehicleId }, { token });
|
||||
},
|
||||
|
||||
addPatientsToIncident: async (incidentId: string, patients: any[], token: string): Promise<ApiResponse<any>> => {
|
||||
return apiClient.post(`/v1/incidents/${incidentId}/patients`, { patients }, { token });
|
||||
}
|
||||
};
|
||||
81
src/api/types.ts
Normal file
81
src/api/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
phone: string;
|
||||
username: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export type LoginResponse = {
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
mfa_required: boolean;
|
||||
mfa_session_token?: string;
|
||||
user?: AuthUser;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type MfaSetupResponse = {
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
totp_uri: string;
|
||||
secret: string;
|
||||
qr_code_base64: string;
|
||||
};
|
||||
}
|
||||
export type MfaVerifyResponse = {
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
backup_codes: string[];
|
||||
};
|
||||
meta: {
|
||||
request_id: string;
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Patient {
|
||||
name: string;
|
||||
age: number;
|
||||
gender: string;
|
||||
triage_level: string;
|
||||
symptoms: { name: string; duration_minutes: number }[];
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
category: string;
|
||||
triage_level: string;
|
||||
severity: string;
|
||||
caller_id: string;
|
||||
organisationId: string | null;
|
||||
gps_lat: number;
|
||||
gps_lon: number;
|
||||
address: string;
|
||||
patients: Patient[];
|
||||
notes: string;
|
||||
guest_name?: string | null;
|
||||
guest_phone?: string | null;
|
||||
status: string;
|
||||
assigned_vehicle: string | null;
|
||||
eta_seconds: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
status: number;
|
||||
message: string;
|
||||
data: T;
|
||||
meta: {
|
||||
request_id: string;
|
||||
timestamp: string;
|
||||
next_cursor?: string | null;
|
||||
total_count?: number;
|
||||
};
|
||||
}
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
126
src/components/Common.tsx
Normal file
126
src/components/Common.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface CardProps {
|
||||
children?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
glowColor?: 'cyan' | 'green' | 'red' | 'amber';
|
||||
onClick?: () => void;
|
||||
value?: string | number;
|
||||
icon?: any;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
className = '',
|
||||
style = {},
|
||||
glowColor,
|
||||
onClick
|
||||
}) => {
|
||||
const glowClass = glowColor ? `glow-${glowColor}` : '';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`glass ${glowClass} ${className}`}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '20px',
|
||||
border: '1px solid var(--card-border)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ width: '100%' }}>
|
||||
{typeof title === 'string' ? (
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{title}</h3>
|
||||
) : title}
|
||||
{subtitle && (
|
||||
typeof subtitle === 'string' ? (
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '4px' }}>{subtitle}</p>
|
||||
) : subtitle
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subValue?: string;
|
||||
icon: any;
|
||||
trend?: {
|
||||
value: string;
|
||||
isUp: boolean;
|
||||
};
|
||||
glowColor: 'cyan' | 'green' | 'red' | 'amber';
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
icon: Icon,
|
||||
trend,
|
||||
glowColor,
|
||||
pulse
|
||||
}) => {
|
||||
const colorVar = `var(--accent-${glowColor === 'red' ? 'red' : glowColor === 'amber' ? 'warning-amber' : glowColor})`;
|
||||
|
||||
return (
|
||||
<Card glowColor={glowColor} style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
background: `rgba(0, 0, 0, 0.3)`,
|
||||
border: `1px solid var(--card-border)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: colorVar
|
||||
}}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
{pulse && <div className="pulse-dot-red"></div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', textTransform: 'uppercase', fontWeight: 600 }}>{label}</p>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', marginTop: '4px' }}>
|
||||
<h2 className="mono" style={{ fontSize: '1.5rem', fontWeight: 700 }}>{value}</h2>
|
||||
{subValue && <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>/ {subValue}</span>}
|
||||
</div>
|
||||
|
||||
{trend && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '0.75rem',
|
||||
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>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
86
src/components/ErrorBoundary.tsx
Normal file
86
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--base-bg)',
|
||||
color: 'var(--text-primary)',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
zIndex: 9999
|
||||
}}>
|
||||
<AlertTriangle size={64} color="var(--alert-red)" style={{ marginBottom: '24px' }} />
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '16px' }}>Terminal Error Detected</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', maxWidth: '500px', marginBottom: '32px' }}>
|
||||
A critical failure occurred in the module hierarchy. The security perimeter remains intact, but the interface requires a full system reset.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<div style={{
|
||||
background: 'rgba(255, 59, 59, 0.05)',
|
||||
border: '1px solid rgba(255, 59, 59, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--alert-red)',
|
||||
marginBottom: '32px',
|
||||
maxWidth: '80%'
|
||||
}}>
|
||||
{this.state.error.toString()}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px 24px',
|
||||
background: 'var(--accent-cyan)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#000',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
REBOOT SYSTEM
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
308
src/components/MfaSettings.tsx
Normal file
308
src/components/MfaSettings.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ShieldCheck, Smartphone, CheckCircle, AlertCircle, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
import { authApi } from '../api/auth';
|
||||
import type { MfaSetupResponse } from '../api/types';
|
||||
import { Card } from './Common';
|
||||
|
||||
export const MfaSettings: React.FC = () => {
|
||||
const [setupData, setSetupData] = useState<MfaSetupResponse['data'] | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [status, setStatus] = useState<'idle' | 'setup' | 'enabled' | 'disable' | 'error'>('idle');
|
||||
const [message, setMessage] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
|
||||
useEffect(() => {
|
||||
const userJson = localStorage.getItem('teleems_user');
|
||||
if (userJson) {
|
||||
const user = JSON.parse(userJson);
|
||||
if (user.mfa_enabled) {
|
||||
setStatus('enabled');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const initiateSetup = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.setupMfa(token);
|
||||
if (response.status === 201) {
|
||||
setSetupData(response.data);
|
||||
setStatus('setup');
|
||||
} else {
|
||||
setMessage(response.message);
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage('Failed to initiate MFA setup');
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyAndEnable = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.verifyTotpSetup(token, totpCode);
|
||||
if (response.status === 201 || response.status === 200) {
|
||||
setBackupCodes(response.data.backup_codes || []);
|
||||
setStatus('enabled');
|
||||
setMessage('MFA has been successfully enabled. Save your backup codes securely.');
|
||||
|
||||
const userJson = localStorage.getItem('teleems_user');
|
||||
if (userJson) {
|
||||
const user = JSON.parse(userJson);
|
||||
user.mfa_enabled = true;
|
||||
localStorage.setItem('teleems_user', JSON.stringify(user));
|
||||
}
|
||||
} else {
|
||||
setMessage(response.message);
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage('Verification failed');
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableMfa = async () => {
|
||||
if (!password) {
|
||||
setMessage('Password is required to disable MFA');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.disableMfa(password, token);
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
setStatus('idle');
|
||||
setMessage('MFA protection has been disabled.');
|
||||
setPassword('');
|
||||
|
||||
const userJson = localStorage.getItem('teleems_user');
|
||||
if (userJson) {
|
||||
const user = JSON.parse(userJson);
|
||||
user.mfa_enabled = false;
|
||||
localStorage.setItem('teleems_user', JSON.stringify(user));
|
||||
}
|
||||
} else {
|
||||
setMessage(response.message || 'Failed to disable MFA. Check your password.');
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage('An error occurred while disabling MFA');
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Card title="Multi-Factor Authentication" subtitle="Enhance your nodal security with TOTP.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{status === 'idle' && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Smartphone size={48} style={{ color: 'var(--text-secondary)', marginBottom: '16px', opacity: 0.5 }} />
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginBottom: '20px' }}>
|
||||
MFA adds an extra layer of protection to your account by requiring a code from your mobile device.
|
||||
</p>
|
||||
<button
|
||||
onClick={initiateSetup}
|
||||
disabled={isLoading}
|
||||
className="login-button"
|
||||
style={{ width: 'auto', padding: '0 32px' }}
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" /> : 'BEGIN SECURE SETUP'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'setup' && setupData && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px' }}>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#fff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 0 20px rgba(0,212,255,0.2)'
|
||||
}}>
|
||||
<img src={setupData.qr_code_base64} alt="MFA QR Code" style={{ width: '180px', height: '180px' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '0.85rem', fontWeight: 600 }}>Scan with Google Authenticator or Authy</p>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
||||
Secret: <span className="mono" style={{ color: 'var(--accent-cyan)' }}>{setupData.secret}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ width: '100%' }}>
|
||||
<label className="input-label">Verification Code</label>
|
||||
<input
|
||||
type="text"
|
||||
className="login-input mono"
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
maxLength={6}
|
||||
style={{ textAlign: 'center', fontSize: '1.2rem', letterSpacing: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={verifyAndEnable}
|
||||
disabled={isLoading || totpCode.length < 6}
|
||||
className="login-button"
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" /> : 'VERIFY & ENABLE MFA'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'enabled' && (
|
||||
<div style={{ textAlign: 'center', padding: '10px 0' }}>
|
||||
<div style={{ color: 'var(--accent-green)', marginBottom: '20px' }}>
|
||||
<CheckCircle size={48} style={{ marginBottom: '16px' }} />
|
||||
<p style={{ fontSize: '1rem', fontWeight: 800 }}>MFA PROTECTION ACTIVE</p>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '8px' }}>
|
||||
Your node is now secured with quantum-ready TOTP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginTop: '20px',
|
||||
textAlign: 'left'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', color: 'var(--accent-blue)' }}>
|
||||
<ShieldCheck size={18} />
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>Emergency Backup Codes</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||
Save these codes in a secure location. You can use them if you lose access to your authenticator device.
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '10px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '8px',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--accent-cyan)',
|
||||
letterSpacing: '1px'
|
||||
}}>
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = backupCodes.join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
}}
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--accent-cyan)',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
COPY ALL CODES
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setStatus('disable')}
|
||||
className="btn-ghost-sm"
|
||||
style={{ width: '100%', marginTop: '24px', color: 'var(--alert-red)', borderColor: 'rgba(255, 59, 59, 0.2)' }}
|
||||
>
|
||||
DISABLE MFA PROTECTION
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'disable' && (
|
||||
<div style={{ textAlign: 'center', padding: '10px 0' }}>
|
||||
<AlertCircle size={48} style={{ color: 'var(--alert-red)', marginBottom: '16px' }} />
|
||||
<p style={{ fontSize: '1rem', fontWeight: 800, color: '#fff' }}>DISABLE PROTECTION?</p>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '8px', marginBottom: '24px' }}>
|
||||
Removing MFA significantly increases the risk of unauthorized access. Please confirm your password to proceed.
|
||||
</p>
|
||||
|
||||
<div style={{ textAlign: 'left', marginBottom: '20px' }}>
|
||||
<label className="input-label">Operator Password</label>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="login-input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ paddingRight: '40px' }}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
/>
|
||||
<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 style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => setStatus('enabled')}
|
||||
className="btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={isLoading}
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDisableMfa}
|
||||
className="login-button"
|
||||
style={{ flex: 1.5, background: 'var(--alert-red)', boxShadow: '0 0 20px rgba(255, 59, 59, 0.3)' }}
|
||||
disabled={isLoading || !password}
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" /> : 'CONFIRM REMOVAL'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{status === 'error' && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0', color: 'var(--alert-red)' }}>
|
||||
<AlertCircle size={48} style={{ marginBottom: '16px' }} />
|
||||
<p style={{ fontSize: '0.9rem' }}>{message}</p>
|
||||
<button onClick={() => setStatus('idle')} style={{ color: 'var(--accent-cyan)', background: 'none', border: 'none', marginTop: '12px', cursor: 'pointer', fontWeight: 600 }}>Try Again</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
246
src/components/PerspectiveSwitcher.tsx
Normal file
246
src/components/PerspectiveSwitcher.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ShieldAlert,
|
||||
Hospital,
|
||||
Monitor,
|
||||
HeartPulse,
|
||||
Truck,
|
||||
Zap,
|
||||
Activity,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
LayoutDashboard,
|
||||
Navigation,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Perspective {
|
||||
id: string;
|
||||
label: string;
|
||||
group: string;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const perspectives: Perspective[] = [
|
||||
{ id: 'CURESELECT_ADMIN', label: 'CureSelect Admin', group: 'System Access', icon: LayoutDashboard, description: 'Full Platform Access' },
|
||||
{ id: 'HOSPITAL_ADMIN', label: 'Hospital Admin', group: 'Hospital Scope', icon: Hospital, description: 'Manage Hospital Assets' },
|
||||
{ id: 'COORDINATOR', label: 'Hospital Coordinator', group: 'Hospital Scope', icon: Monitor, description: 'Manage Operations' },
|
||||
{ id: 'ED_DOCTOR', label: 'Emergency Doctor', group: 'Hospital Scope', icon: HeartPulse, description: 'Clinical Intelligence' },
|
||||
{ id: 'FLEET_OPERATOR', label: 'Fleet Operator', group: 'Fleet Scope', icon: Truck, description: 'Dispatch & Logistics' },
|
||||
{ id: 'STATION_INCHARGE', label: 'Station Incharge', group: 'Fleet Scope', icon: Zap, description: 'Node Management' },
|
||||
{ id: 'PILOT', label: 'Ambulance Pilot', group: 'Fleet Scope', icon: Navigation, description: 'Trip Management' },
|
||||
{ id: 'EMT', label: 'EMT/Paramedic', group: 'Fleet Scope', icon: Activity, description: 'Patient Care' },
|
||||
{ id: 'HOSPITAL_GROUP', label: 'Hospital Group', group: 'Regional Scope', icon: ShieldAlert, description: 'Regional Management' },
|
||||
{ id: 'PROVIDER', label: 'Provider Portal', group: 'Clinical Scope', icon: Activity, description: 'Healthcare Provider' },
|
||||
{ id: 'PATIENT', label: 'Patient Portal', group: 'Patient Scope', icon: User, description: 'Personal Health' },
|
||||
{ id: 'SCAN_CENTRE', label: 'Scan Centre', group: 'Diagnostic Scope', icon: Monitor, description: 'Imaging & Diagnostics' },
|
||||
{ id: 'CART', label: 'Mobile Cart', group: 'Field Scope', icon: Truck, description: 'Mobile Response' },
|
||||
];
|
||||
|
||||
export const PerspectiveSwitcher: React.FC<{ currentRole: string; onSwitch: (role: string) => void }> = ({
|
||||
currentRole,
|
||||
onSwitch
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentPerspective = perspectives.find(p => p.id === currentRole) || perspectives[0];
|
||||
const Icon = currentPerspective.icon;
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const groupedPerspectives = perspectives.reduce((acc, p) => {
|
||||
if (!acc[p.group]) acc[p.group] = [];
|
||||
acc[p.group].push(p);
|
||||
return acc;
|
||||
}, {} as Record<string, Perspective[]>);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
|
||||
<span style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '6px',
|
||||
display: 'block',
|
||||
marginLeft: '4px'
|
||||
}}>
|
||||
Perspective Switcher
|
||||
</span>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-primary)',
|
||||
textAlign: 'left',
|
||||
transition: 'border-color 0.2s',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '6px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--accent-cyan)',
|
||||
}}>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{currentPerspective.label}
|
||||
</div>
|
||||
</div>
|
||||
{isOpen ? <ChevronUp size={14} color="var(--text-secondary)" /> : <ChevronDown size={14} color="var(--text-secondary)" />}
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="glass"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
width: '240px',
|
||||
marginBottom: '12px',
|
||||
padding: '8px',
|
||||
zIndex: 2000,
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--card-border)',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{Object.entries(groupedPerspectives).map(([group, items]) => (
|
||||
<div key={group} style={{ marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
fontSize: '0.55rem',
|
||||
fontWeight: 800,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
padding: '4px 8px',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{group}
|
||||
</div>
|
||||
{items.map((p) => {
|
||||
const ItemIcon = p.icon;
|
||||
const isActive = p.id === currentRole;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
onSwitch(p.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: isActive ? 'rgba(59, 130, 246, 0.08)' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s',
|
||||
color: isActive ? 'var(--accent-cyan)' : 'var(--text-primary)',
|
||||
marginBottom: '2px'
|
||||
}}
|
||||
className={isActive ? '' : 'table-row-hover'}
|
||||
>
|
||||
<div style={{
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '5px',
|
||||
background: isActive ? 'rgba(59, 130, 246, 0.15)' : 'rgba(0,0,0,0.03)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<ItemIcon size={12} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: isActive ? 700 : 600 }}>{p.label}</div>
|
||||
<div style={{ fontSize: '0.58rem', color: 'var(--text-secondary)', opacity: 0.8 }}>{p.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.05)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = '/launcher';
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
borderRadius: '6px',
|
||||
border: '1px dashed var(--accent-cyan)',
|
||||
background: 'rgba(59, 130, 246, 0.03)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--accent-cyan)',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
className="table-row-hover"
|
||||
>
|
||||
<LayoutDashboard size={14} />
|
||||
SWITCH SYSTEM PORTAL
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
233
src/components/Sidebar.tsx
Normal file
233
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LogOut,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { PerspectiveSwitcher } from './PerspectiveSwitcher';
|
||||
import { NAVIGATION_CONFIG } from '../config/navigation';
|
||||
import type { NavItem } from '../config/navigation';
|
||||
import { logout } from '../utils/auth';
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Safely parse user data
|
||||
const user = useMemo(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('teleems_user');
|
||||
if (!stored || stored === 'undefined' || stored === 'null') return { roles: [] };
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
parsed.roles = Array.isArray(parsed.roles)
|
||||
? parsed.roles.map((r: any) => String(r).toUpperCase())
|
||||
: [];
|
||||
return parsed;
|
||||
}
|
||||
return { roles: [] };
|
||||
} catch (err) {
|
||||
console.error('Failed to parse user data from localStorage', err);
|
||||
return { roles: [] };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const handleRoleSwitch = (role: string) => {
|
||||
const updatedUser = { ...user, roles: [role.toUpperCase()] };
|
||||
localStorage.setItem('teleems_user', JSON.stringify(updatedUser));
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const currentRole = user.roles?.[0] || 'CURESELECT_ADMIN';
|
||||
const displayName = String(user.username || (currentRole === 'CURESELECT_ADMIN' ? 'CureSelect Admin' : 'Admin User'));
|
||||
const displayId = user.id ? `#${String(user.id).substring(0, 6)}` : '#789022';
|
||||
const initials = currentRole === 'CURESELECT_ADMIN' ? 'CA' : (displayName.substring(0, 2).toUpperCase() || 'AU');
|
||||
|
||||
const filteredNavItems = useMemo(() => {
|
||||
const userRoles = Array.isArray(user.roles) ? user.roles : [];
|
||||
const adminRoles = ['CURESELECT_ADMIN', 'ADMIN', 'SUPER_ADMIN', 'SUPERADMIN'];
|
||||
const hasAdminRole = userRoles.some(r => adminRoles.includes(r));
|
||||
|
||||
const filterItems = (items: NavItem[]): NavItem[] => {
|
||||
return items.filter(item => {
|
||||
const hasItemRole = item.roles.some(role => userRoles.includes(role.toUpperCase()));
|
||||
return hasAdminRole || hasItemRole;
|
||||
}).map(item => ({
|
||||
...item,
|
||||
subItems: item.subItems ? filterItems(item.subItems) : undefined
|
||||
}));
|
||||
};
|
||||
|
||||
return filterItems(NAVIGATION_CONFIG);
|
||||
}, [user.roles]);
|
||||
|
||||
const renderNavItem = (item: NavItem, isSubItem = false) => {
|
||||
const Icon = item.icon || AlertCircle;
|
||||
const isActive = location.pathname === item.path || (item.path.includes('?') && location.pathname + location.search === item.path);
|
||||
const hasSubItems = item.subItems && item.subItems.length > 0;
|
||||
const isParentActive = hasSubItems && (isActive || item.subItems?.some(sub => location.pathname === sub.path.split('?')[0]));
|
||||
|
||||
return (
|
||||
<div key={item.id} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
style={({ isActive: linkActive }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: isSubItem ? '8px 20px 8px 48px' : '10px 20px',
|
||||
textDecoration: 'none',
|
||||
color: (linkActive || isActive) ? 'var(--accent-cyan)' : 'var(--text-secondary)',
|
||||
borderLeft: !isSubItem && (linkActive || isActive) ? '3px solid var(--accent-cyan)' : '3px solid transparent',
|
||||
background: (linkActive || isActive) ? 'rgba(59, 130, 246, 0.08)' : 'transparent',
|
||||
transition: 'all 0.25s ease',
|
||||
textShadow: (linkActive || isActive) ? '0 0 10px rgba(59, 130, 246, 0.4)' : 'none',
|
||||
minWidth: 0,
|
||||
position: 'relative'
|
||||
})}
|
||||
>
|
||||
{!isSubItem && <Icon size={18} style={{ flexShrink: 0 }} />}
|
||||
<span style={{
|
||||
fontWeight: (isActive || isParentActive) ? 700 : 500,
|
||||
fontSize: isSubItem ? '0.8rem' : '0.875rem',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1
|
||||
}}>
|
||||
{item.label}
|
||||
</span>
|
||||
{hasSubItems && (
|
||||
isParentActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<AnimatePresence>
|
||||
{hasSubItems && isParentActive && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden', background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
{item.subItems?.map(sub => renderNavItem(sub, true))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="glass" style={{
|
||||
width: 'var(--sidebar-width)',
|
||||
minWidth: 'var(--sidebar-width)',
|
||||
flexBasis: 'var(--sidebar-width)',
|
||||
flexShrink: 0,
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid var(--card-border)',
|
||||
background: 'var(--glass-bg)',
|
||||
zIndex: 1100,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 20px',
|
||||
borderBottom: '1px solid var(--card-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
background: 'var(--accent-cyan)',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 0 12px var(--accent-cyan)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Zap size={18} color="#000" />
|
||||
</div>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 800, color: 'var(--accent-cyan)', margin: 0, letterSpacing: '-0.3px' }}>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 var(--card-border)',
|
||||
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: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, var(--accent-cyan), var(--accent-green))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
flexShrink: 0,
|
||||
}}>{initials}</div>
|
||||
<div style={{ minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayName}</div>
|
||||
<div style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>ID: {displayId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="hover-glow"
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '7px',
|
||||
color: 'var(--alert-red)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Sign Out"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
286
src/components/TopBar.tsx
Normal file
286
src/components/TopBar.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, Bell, Clock, LogOut, Home, ArrowLeft } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { logout } from '../utils/auth';
|
||||
|
||||
export const TopBar: React.FC = () => {
|
||||
const [time, setTime] = useState(new Date());
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const user = React.useMemo(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('teleems_user');
|
||||
if (!stored || stored === 'undefined' || stored === 'null') return {};
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const displayName = String(user.username || 'Admin');
|
||||
const rawRole = Array.isArray(user.roles) ? (user.roles[0] || 'Administrator') : 'Administrator';
|
||||
// Shorten long role names for the header
|
||||
const roleLabel = rawRole
|
||||
.replace(/_/g, ' ')
|
||||
.replace('CURESELECT ADMIN', 'CS ADMIN')
|
||||
.replace('HOSPITAL ADMIN', 'H. ADMIN')
|
||||
.replace('FLEET OPERATOR', 'FLEET OPS')
|
||||
.replace('STATION INCHARGE', 'STATION IC');
|
||||
const initials = displayName.substring(0, 2).toUpperCase() || 'AD';
|
||||
|
||||
const formattedTime = time.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
timeZone: 'Asia/Kolkata',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
const tzLabel = 'IST';
|
||||
|
||||
return (
|
||||
<header
|
||||
className="glass"
|
||||
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
|
||||
onClick={() => navigate(-1)}
|
||||
className="hover-glow"
|
||||
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} />
|
||||
<span>BACK</span>
|
||||
</button>
|
||||
|
||||
{/* Home Navigation Button */}
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="hover-glow"
|
||||
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} />
|
||||
<span>HOME</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<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
|
||||
type="text"
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* ── RIGHT: Clock + Bell + Profile ───────────────────────── */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px', flexShrink: 0 }}>
|
||||
{/* Clock */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-secondary)', flexShrink: 0 }}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Bell */}
|
||||
<div style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}>
|
||||
<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>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ height: '20px', width: '1px', background: 'var(--card-border)', flexShrink: 0 }} />
|
||||
|
||||
{/* Profile */}
|
||||
<div
|
||||
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}
|
||||
title="Click to logout"
|
||||
>
|
||||
{/* 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}
|
||||
</div>
|
||||
|
||||
{/* Name + Role */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||
<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>
|
||||
|
||||
<LogOut size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
137
src/config/navigation.ts
Normal file
137
src/config/navigation.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Activity,
|
||||
Truck,
|
||||
HeartPulse,
|
||||
Hospital,
|
||||
PieChart,
|
||||
Users,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
LayoutDashboard,
|
||||
Monitor,
|
||||
Database,
|
||||
Zap,
|
||||
PhoneCall,
|
||||
Navigation,
|
||||
ShoppingCart,
|
||||
LayoutGrid
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
path: string;
|
||||
roles: string[];
|
||||
subItems?: NavItem[];
|
||||
portalContext?: string; // If set, this item and its sub-items belong to a specific portal
|
||||
}
|
||||
|
||||
export const NAVIGATION_CONFIG: NavItem[] = [
|
||||
{
|
||||
id: 'launcher',
|
||||
label: 'Portal Hub',
|
||||
icon: Monitor,
|
||||
path: '/launcher',
|
||||
roles: ['CURESELECT_ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Admin Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
path: '/',
|
||||
roles: ['CURESELECT_ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'incidents',
|
||||
label: 'Incident Command',
|
||||
icon: Activity,
|
||||
path: '/incidents',
|
||||
roles: ['CURESELECT_ADMIN', 'PILOT', 'COORDINATOR']
|
||||
},
|
||||
{
|
||||
id: 'caller',
|
||||
label: 'Caller Portal',
|
||||
icon: PhoneCall,
|
||||
path: '/caller',
|
||||
roles: ['CURESELECT_ADMIN', 'CALLER']
|
||||
},
|
||||
{
|
||||
id: '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-personnel', label: 'Personnel Hub', 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-inventory', label: 'Supply Chain', 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: 'clinical',
|
||||
label: 'Clinical Intelligence',
|
||||
icon: HeartPulse,
|
||||
path: '/clinical',
|
||||
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'EMT']
|
||||
},
|
||||
{
|
||||
id: 'hospitals',
|
||||
label: 'Hospital Network',
|
||||
icon: Hospital,
|
||||
path: '/hospitals',
|
||||
roles: ['CURESELECT_ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'hospital-console',
|
||||
label: 'Hospital Ops',
|
||||
icon: Monitor,
|
||||
path: '/hospital-console',
|
||||
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT']
|
||||
},
|
||||
{
|
||||
id: 'master-data',
|
||||
label: 'Platform Masters',
|
||||
icon: Database,
|
||||
path: '/master-data',
|
||||
roles: ['CURESELECT_ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Business Intelligence',
|
||||
icon: PieChart,
|
||||
path: '/analytics',
|
||||
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'STATION_INCHARGE']
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Identity & Access',
|
||||
icon: Users,
|
||||
path: '/users',
|
||||
roles: ['CURESELECT_ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: 'System Config',
|
||||
icon: Settings,
|
||||
path: '/config',
|
||||
roles: ['CURESELECT_ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
label: 'Audit & Compliance',
|
||||
icon: ShieldCheck,
|
||||
path: '/compliance',
|
||||
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN']
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
label: 'Infrastructure Health',
|
||||
icon: Zap,
|
||||
path: '/health',
|
||||
roles: ['CURESELECT_ADMIN']
|
||||
},
|
||||
];
|
||||
252
src/index.css
Normal file
252
src/index.css
Normal file
@@ -0,0 +1,252 @@
|
||||
@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 {
|
||||
--base-bg: #F8FAFC;
|
||||
--card-bg: #FFFFFF;
|
||||
--card-border: rgba(59, 130, 246, 0.15);
|
||||
--accent-cyan: #3B82F6;
|
||||
--accent-green: #10B981;
|
||||
--alert-red: #EF4444;
|
||||
--warning-amber: #F59E0B;
|
||||
--text-primary: #1E293B;
|
||||
--text-secondary: #64748B;
|
||||
--glass-bg: rgba(255, 255, 255, 0.8);
|
||||
--glass-blur: blur(12px);
|
||||
--sidebar-width: 260px;
|
||||
--topbar-height: 70px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
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);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Glassmorphism Utility */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* Glow Effects */
|
||||
.glow-cyan {
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.glow-green {
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.glow-red {
|
||||
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.glow-amber {
|
||||
box-shadow: 0 8px 24px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.glow-text-cyan {
|
||||
text-shadow: 0 0 4px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Common Layout Components */
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--card-border) transparent;
|
||||
}
|
||||
|
||||
.page-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.page-container::-webkit-scrollbar-thumb {
|
||||
background: var(--card-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Grid Layouts */
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr 1fr;
|
||||
gap: 24px;
|
||||
height: calc(100% - 140px);
|
||||
}
|
||||
|
||||
.main-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Pulse Animation */
|
||||
@keyframes pulse-red {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.5); opacity: 0.5; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.pulse-dot-red {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--alert-red);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
animation: pulse-red 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-cyan {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.5); opacity: 0.5; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.pulse-dot-cyan {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.3);
|
||||
animation: pulse-cyan 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100vh); }
|
||||
}
|
||||
|
||||
.scanline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(59, 130, 246, 0.05), transparent);
|
||||
animation: scanline 8s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Scrollbar Hide for specific panels if needed */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover-glow:hover {
|
||||
border-color: var(--accent-cyan) !important;
|
||||
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.status-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-pulse::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 50%;
|
||||
background: inherit;
|
||||
animation: pulse-ring 1.5s cubic-bezier(0.24, 0, 0.38, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(1); opacity: 0.8; }
|
||||
100% { transform: scale(3.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.table-row-hover {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row-hover:hover {
|
||||
background: rgba(59, 130, 246, 0.04) !important;
|
||||
}
|
||||
|
||||
.input-glow:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan) !important;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Global Dropdown Styling */
|
||||
select, select option {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
259
src/pages/AnalyticsReports.tsx
Normal file
259
src/pages/AnalyticsReports.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Download,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
Map as MapIcon,
|
||||
BarChart3,
|
||||
PieChart as PieChartIcon,
|
||||
Clock,
|
||||
Filter,
|
||||
Users,
|
||||
Building2,
|
||||
Package,
|
||||
Layers,
|
||||
MapPin,
|
||||
Crosshair
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../components/Common';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
Cell,
|
||||
PieChart,
|
||||
Pie
|
||||
} from 'recharts';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const incidentTrend = [
|
||||
{ day: 'Mon', count: 42, critical: 12 },
|
||||
{ day: 'Tue', count: 38, critical: 8 },
|
||||
{ day: 'Wed', count: 55, critical: 18 },
|
||||
{ day: 'Thu', count: 48, critical: 10 },
|
||||
{ day: 'Fri', count: 70, critical: 25 },
|
||||
{ day: 'Sat', count: 85, critical: 32 },
|
||||
{ day: 'Sun', count: 62, critical: 20 },
|
||||
];
|
||||
|
||||
const operatorPerformance = [
|
||||
{ name: 'Lifeline', trips: 1450, utl: 85 },
|
||||
{ name: 'Apex', trips: 1280, utl: 78 },
|
||||
{ name: 'Mercy', trips: 920, utl: 72 },
|
||||
{ name: 'CureMove', trips: 1100, utl: 81 },
|
||||
];
|
||||
|
||||
const triageDistribution = [
|
||||
{ name: 'Immediate (Red)', value: 15, color: '#ff3b3b' },
|
||||
{ name: 'Urgent (Orange)', value: 25, color: '#ffb800' },
|
||||
{ name: 'Minor (Green)', value: 45, color: '#00ff88' },
|
||||
{ name: 'Non-Emg (White)', value: 10, color: '#E2E8F0' },
|
||||
{ name: 'IFT (Blue)', value: 5, color: '#3B82F6' },
|
||||
];
|
||||
|
||||
type AnalyticsView = 'COMM_CENTER' | 'AGGREGATORS_FLEET' | 'HOSPITALS_CLINICAL' | 'INVENTORY_RESOURCES';
|
||||
|
||||
export const AnalyticsReports: React.FC = () => {
|
||||
const [activeView, setActiveView] = useState<AnalyticsView>('COMM_CENTER');
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '2.5rem', fontWeight: 900, background: 'linear-gradient(90deg, var(--accent-blue), var(--text-primary))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
Platform Intelligence
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
Real-time KPIs, cross-entity performance metrics, and geographic incident heatmaps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<div className="glass" style={{ padding: '4px', borderRadius: '12px', display: 'flex', gap: '4px', background: 'rgba(0,0,0,0.03)' }}>
|
||||
{(['COMM_CENTER', 'AGGREGATORS_FLEET', 'HOSPITALS_CLINICAL', 'INVENTORY_RESOURCES'] as AnalyticsView[]).map(view => (
|
||||
<button
|
||||
key={view}
|
||||
onClick={() => setActiveView(view)}
|
||||
style={{
|
||||
padding: '10px 16px', borderRadius: '8px', border: 'none',
|
||||
background: activeView === view ? 'var(--accent-blue)' : 'transparent',
|
||||
color: activeView === view ? '#fff' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', fontSize: '0.7rem'
|
||||
}}>
|
||||
{view.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button style={{ padding: '12px 24px', background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-blue)', borderRadius: '10px', color: 'var(--accent-blue)', fontWeight: 800, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px', transition: 'all 0.3s ease' }}>
|
||||
<Download size={18} /> EXPORT PDF/CSV
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* GLOBAL HUD STATS */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '20px' }}>
|
||||
{[
|
||||
{ label: 'Total Incidents', value: '18,242', trend: '+5.2%', color: 'cyan' },
|
||||
{ label: 'Avg Dispatch Time', value: '1.42m', trend: '-22s', color: 'green' },
|
||||
{ label: 'Active ePCR Count', value: '4,102', trend: '+12%', color: 'amber' },
|
||||
{ label: 'Hospital Pre-Alerts', value: '892', trend: '+8%', color: 'cyan' },
|
||||
{ label: 'System Uptime', value: '99.99%', trend: 'Stable', color: 'green' },
|
||||
].map(stat => (
|
||||
<Card key={stat.label} style={{ padding: '20px' }} className={`glow-${stat.color}`}>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 700 }}>{stat.label}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', marginTop: '10px' }}>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 900 }} className="mono">{stat.value}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: stat.trend.startsWith('+') ? 'var(--accent-green)' : (stat.trend === 'Stable' ? 'var(--accent-cyan)' : 'var(--alert-red)') }}>{stat.trend}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{activeView === 'COMM_CENTER' && (
|
||||
<motion.div key="comm" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.8fr 1.2fr', gap: '24px' }}>
|
||||
<Card title="Incident Frequency Trend" subtitle="Daily critical vs standard emergencies.">
|
||||
<div style={{ height: '350px', marginTop: '20px' }}>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<AreaChart data={incidentTrend}>
|
||||
<defs>
|
||||
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--accent-cyan)" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="var(--accent-cyan)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorCritical" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--alert-red)" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="var(--alert-red)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0,0,0,0.05)" vertical={false} />
|
||||
<XAxis dataKey="day" stroke="var(--text-secondary)" fontSize={12} tick={{fontWeight: 600}} />
|
||||
<YAxis stroke="var(--text-secondary)" fontSize={12} />
|
||||
<Tooltip contentStyle={{ background: 'var(--base-bg)', border: '1px solid var(--card-border)', borderRadius: '12px' }} />
|
||||
<Area type="monotone" dataKey="count" stroke="var(--accent-cyan)" strokeWidth={3} fillOpacity={1} fill="url(#colorCount)" name="Total Incidents" />
|
||||
<Area type="monotone" dataKey="critical" stroke="var(--alert-red)" strokeWidth={3} fillOpacity={1} fill="url(#colorCritical)" name="Critical (Red)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Traffic Segmentation" subtitle="Distribution by Triage Category.">
|
||||
<div style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={triageDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={70}
|
||||
outerRadius={100}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{triageDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ background: 'var(--base-bg)', border: '1px solid var(--card-border)', borderRadius: '12px' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '10px', marginTop: '10px' }}>
|
||||
{triageDistribution.map(td => (
|
||||
<div key={td.name} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: td.color }} />
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>{td.name}</span>
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 800, marginLeft: 'auto' }}>{td.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '24px' }}>
|
||||
<Card title="Geographic Heatmap" subtitle="Active incident hotspots by district.">
|
||||
<div style={{ height: '220px', background: 'rgba(0,0,0,0.03)', borderRadius: '12px', border: '1px solid var(--card-border)', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.3, background: 'url("https://upload.wikimedia.org/wikipedia/commons/4/4b/Tamil_Nadu_districts_map.svg") center/contain no-repeat' }} />
|
||||
<div style={{ position: 'absolute', top: '20%', left: '45%', width: '40px', height: '40px', background: 'radial-gradient(circle, rgba(255,59,59,0.8) 0%, transparent 70%)' }} />
|
||||
<div style={{ position: 'absolute', top: '50%', left: '30%', width: '30px', height: '30px', background: 'radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%)' }} />
|
||||
<div style={{ position: 'absolute', bottom: '10px', right: '10px', fontSize: '0.6rem', color: 'var(--text-secondary)', background: 'rgba(255,255,255,0.8)', padding: '4px 8px', borderRadius: '4px' }}>
|
||||
Live Feed: Chennai Hub
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="CCE Performance" subtitle="Average handle time (AHT) per shift.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{[
|
||||
{ shift: 'Morning (SH1)', aht: '1m 12s', target: '1m 30s', status: 'Optimal' },
|
||||
{ shift: 'Afternoon (SH2)', aht: '1m 28s', target: '1m 30s', status: 'Healthy' },
|
||||
{ shift: 'Night (SH3)', aht: '1m 45s', target: '1m 30s', status: 'Delayed' },
|
||||
].map((cce, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 800 }}>{cce.shift}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>Log: {cce.aht} (Limit: {cce.target})</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 800, color: cce.status === 'Optimal' ? 'var(--accent-green)' : (cce.status === 'Healthy' ? 'var(--accent-cyan)' : 'var(--alert-red)') }}>{cce.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Resource Health" subtitle="Mobile medical unit status.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '0.75rem' }}>ALS Availability</span>
|
||||
<span className="mono" style={{ fontSize: '0.8rem', fontWeight: 800, color: 'var(--accent-cyan)' }}>88%</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '6px', background: 'rgba(0,0,0,0.03)', borderRadius: '3px' }}>
|
||||
<div style={{ width: '88%', height: '100%', background: 'var(--accent-cyan)', borderRadius: '3px' }}></div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', marginTop: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem' }}>BLS Availability</span>
|
||||
<span className="mono" style={{ fontSize: '0.8rem', fontWeight: 800, color: 'var(--accent-green)' }}>95%</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '6px', background: 'rgba(0,0,0,0.03)', borderRadius: '3px' }}>
|
||||
<div style={{ width: '95%', height: '100%', background: 'var(--accent-green)', borderRadius: '3px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'AGGREGATORS_FLEET' && (
|
||||
<motion.div key="agg" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }}>
|
||||
<Card title="Operator Unit Utilization" subtitle="Total trips vs vehicle utilization rate (last 30 days).">
|
||||
<div style={{ height: '400px', marginTop: '24px' }}>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={operatorPerformance}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0,0,0,0.05)" vertical={false} />
|
||||
<XAxis dataKey="name" stroke="var(--text-secondary)" fontSize={12} tick={{fontWeight: 700}} />
|
||||
<YAxis yAxisId="left" stroke="var(--text-secondary)" fontSize={12} />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="var(--text-secondary)" fontSize={12} unit="%" />
|
||||
<Tooltip contentStyle={{ background: 'var(--base-bg)', border: '1px solid var(--card-border)', borderRadius: '12px' }} />
|
||||
<Bar yAxisId="left" dataKey="trips" fill="var(--accent-cyan)" radius={[4, 4, 0, 0]} name="Total Trips" barSize={40} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="utl" stroke="var(--accent-green)" strokeWidth={3} name="Utilization (%)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
239
src/pages/AuditCompliance.tsx
Normal file
239
src/pages/AuditCompliance.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Search,
|
||||
Download,
|
||||
History,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Database,
|
||||
ChevronRight,
|
||||
User as UserIcon,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../components/Common';
|
||||
import { motion } from 'framer-motion';
|
||||
import { authApi } from '../api/auth';
|
||||
|
||||
export const AuditCompliance: React.FC = () => {
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setIsLoading(true);
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
try {
|
||||
const response = await authApi.getAuditLogs(token);
|
||||
if (response && response.status === 200 && response.data) {
|
||||
setLogs(Array.isArray(response.data.logs) ? response.data.logs : (Array.isArray(response.data) ? response.data : []));
|
||||
setTotal(response.data.total || (Array.isArray(response.data) ? response.data.length : 0));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch audit logs', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
const getSeverity = (action: string) => {
|
||||
if (action.includes('FAILED') || action.includes('BREACH')) return 'High';
|
||||
if (action.includes('MFA') || action.includes('RESET')) return 'Medium';
|
||||
return 'Low';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '2.5rem', fontWeight: 900, background: 'linear-gradient(90deg, #3B82F6, #fff)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
Audit & Compliance
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
Immutable action logs, HIPAA breach detection, and automated compliance verification.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
style={{ padding: '12px 24px', background: 'rgba(255,255,255,0.05)', border: '1px solid var(--card-border)', borderRadius: '10px', color: '#fff', fontWeight: 800, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px' }}
|
||||
>
|
||||
<RefreshCw size={18} color="var(--accent-cyan)" className={isLoading ? 'spin-slow' : ''} /> SYNC LOGS
|
||||
</button>
|
||||
<button style={{ padding: '12px 24px', background: 'var(--accent-cyan)', color: '#000', border: 'none', borderRadius: '10px', fontWeight: 800, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px', boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)' }}>
|
||||
<Download size={18} /> GENERATE REPORT
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2.5fr 1fr', gap: '32px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
{/* 5.9 Audit Trail */}
|
||||
<Card title="System-Wide Immutable Audit Trail" subtitle={`Displaying ${logs.length} of ${total} security events.`}>
|
||||
<div style={{ position: 'relative', marginBottom: '24px' }}>
|
||||
<Search size={18} style={{ position: 'absolute', left: '14px', top: '14px', color: 'var(--text-secondary)' }} />
|
||||
<input type="text" placeholder="Search logs by user, action, or entity ID..." style={{ width: '100%', padding: '14px 14px 14px 44px', background: 'rgba(0,0,0,0.3)', border: '1px solid var(--card-border)', borderRadius: '10px', color: '#fff' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(255,255,255,0.03)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '16px' }}>Timestamp</th>
|
||||
<th style={{ padding: '16px' }}>Actor</th>
|
||||
<th style={{ padding: '16px' }}>Action Node</th>
|
||||
<th style={{ padding: '16px' }}>Client Info</th>
|
||||
<th style={{ padding: '16px' }}>Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, i) => {
|
||||
const severity = getSeverity(log.action);
|
||||
return (
|
||||
<tr key={log.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)', background: severity === 'High' ? 'rgba(255, 59, 59, 0.02)' : 'transparent' }}>
|
||||
<td style={{ padding: '16px' }} className="mono">{new Date(log.createdAt).toLocaleString()}</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<UserIcon size={14} color="var(--accent-cyan)" />
|
||||
<span style={{ fontWeight: 700 }}>{log.user?.username || 'System'}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '4px' }}>{log.user?.email || 'INTERNAL'}</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{ padding: '4px 8px', background: 'rgba(255,255,255,0.05)', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 800 }}>{log.action}</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: 'var(--text-secondary)' }}>
|
||||
<div style={{ fontSize: '0.75rem' }}>IP: {log.ipAddress}</div>
|
||||
<div style={{ fontSize: '0.65rem', opacity: 0.6, maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.userAgent}</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
color: severity === 'High' ? 'var(--alert-red)' : (severity === 'Medium' ? 'var(--warning-amber)' : 'var(--accent-green)')
|
||||
}}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: 'currentColor' }} />
|
||||
<span style={{ fontWeight: 800, fontSize: '0.75rem' }}>{severity.toUpperCase()}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}>
|
||||
<button style={{ padding: '10px 20px', background: 'transparent', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', borderRadius: '8px', fontSize: '0.8rem', cursor: 'pointer' }}>LOAD PREVIOUS SESSIONS</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||
<Card title="HIPAA Breach Detection" subtitle="Active PHI access monitoring.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ padding: '16px', borderRadius: '12px', background: 'rgba(0,255,136,0.05)', border: '1px solid rgba(0,255,136,0.1)', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<CheckCircle2 size={24} color="var(--accent-green)" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: '0.9rem' }}>No Anomalies Detected</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>All PHI access patterns match verified clinical roles.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem', padding: '10px 0', borderBottom: '1px solid var(--card-border)' }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Unauthorized Access Attempts</span>
|
||||
<span style={{ fontWeight: 800 }}>0</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem', padding: '10px 0', borderBottom: '1px solid var(--card-border)' }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Bulk Patient Exports</span>
|
||||
<span style={{ fontWeight: 800 }}>1 (Admin Approved)</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Backup Verification" subtitle="Automated recovery health check.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Database Integrity</span>
|
||||
<span className="mono" style={{ fontWeight: 800, color: 'var(--accent-cyan)' }}>100%</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '6px', background: 'rgba(255,255,255,0.05)', borderRadius: '3px' }}>
|
||||
<div style={{ width: '100%', height: '100%', background: 'linear-gradient(90deg, var(--accent-cyan), var(--accent-green))', borderRadius: '3px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', padding: '12px', background: 'rgba(0,0,0,0.2)', borderRadius: '8px' }}>
|
||||
<strong>Last Verified:</strong> 2026-04-15 04:00 AM (UTC)
|
||||
<br/>
|
||||
<strong>Snapshot ID:</strong> snap-0a88b22-prod-db
|
||||
</div>
|
||||
<button style={{ padding: '8px', background: 'transparent', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-secondary)', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer' }}>TRIGGER MANUAL BACKUP</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<Card title="Compliance Shortcuts">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<button style={{ padding: '14px', background: 'rgba(255,255,255,0.03)', border: '1px solid var(--card-border)', borderRadius: '10px', color: '#fff', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer' }}>
|
||||
<Lock size={18} color="var(--accent-cyan)" />
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>Privacy Impact Report</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>Quarterly PHI access analysis</div>
|
||||
</div>
|
||||
</button>
|
||||
<button style={{ padding: '14px', background: 'rgba(255,255,255,0.03)', border: '1px solid var(--card-border)', borderRadius: '10px', color: '#fff', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer' }}>
|
||||
<AlertTriangle size={18} color="var(--warning-amber)" />
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>Breach Response Plan</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>Standard protocols for leaks</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Identity Access Logs">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{[
|
||||
{ user: 'Arun K.', time: '10:42 AM', device: 'Chrome / macOS' },
|
||||
{ user: 'Meena D.', time: '09:15 AM', device: 'Safari / iPhone' },
|
||||
{ user: 'Naveen P.', time: '08:44 AM', device: 'Chrome / Win11' },
|
||||
].map((auth, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<div style={{ width: '32px', height: '32px', borderRadius: '50%', background: 'rgba(59, 130, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.7rem', fontWeight: 800, color: 'var(--accent-cyan)' }}>
|
||||
{auth.user.split(' ').map(n=>n[0]).join('')}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 700 }}>{auth.user}</div>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)' }}>{auth.time} • {auth.device}</div>
|
||||
</div>
|
||||
<Eye size={14} color="var(--text-secondary)" style={{ cursor: 'pointer' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Legal Documents">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.75rem' }}>
|
||||
<span>Data Privacy Addendum</span>
|
||||
<ExternalLink size={14} color="var(--accent-cyan)" style={{ cursor: 'pointer' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.75rem' }}>
|
||||
<span>HIPAA BAA Agreement</span>
|
||||
<ExternalLink size={14} color="var(--accent-cyan)" style={{ cursor: 'pointer' }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
853
src/pages/CallerPortal.tsx
Normal file
853
src/pages/CallerPortal.tsx
Normal file
@@ -0,0 +1,853 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { incidentsApi } from '../api/incidents';
|
||||
import type { Patient, Incident } from '../api/types';
|
||||
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
MapPin,
|
||||
Clock,
|
||||
User,
|
||||
Activity,
|
||||
ChevronRight,
|
||||
PhoneCall,
|
||||
Truck,
|
||||
Hospital,
|
||||
UserCheck,
|
||||
Navigation,
|
||||
X,
|
||||
AlertCircle,
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
Minus,
|
||||
Info,
|
||||
Heart,
|
||||
ChevronLeft,
|
||||
Zap,
|
||||
LayoutDashboard,
|
||||
Signal
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../components/Common';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// --- Types & Constants ---
|
||||
type TriageCategory = 'RED' | 'ORANGE' | 'GREEN' | 'WHITE' | 'BLACK';
|
||||
|
||||
interface BookingRecord {
|
||||
id: string;
|
||||
caller: string;
|
||||
phone: string;
|
||||
triage: TriageCategory;
|
||||
pickupLocation: string;
|
||||
patients: number;
|
||||
symptoms: string[];
|
||||
status: 'PENDING' | 'DISPATCHED' | 'EN_ROUTE' | 'ON_SITE' | 'COMPLETED' | 'CANCELLED';
|
||||
timestamp: string;
|
||||
eta?: string;
|
||||
receivedBy: string;
|
||||
fleetOperator: string;
|
||||
assignedVehicle: string;
|
||||
destinationHospital: string;
|
||||
}
|
||||
|
||||
const CATEGORIES: { type: TriageCategory; label: string; description: string; color: string; icon: any }[] = [
|
||||
{ type: 'RED', label: 'Life Threatening', description: 'Cardiac arrest, severe injury, unconscious', color: '#ff4d4d', icon: ShieldAlert },
|
||||
{ type: 'ORANGE', label: 'Urgent', description: 'Fractures, moderate injuries, severe pain', color: '#ffa500', icon: AlertCircle },
|
||||
{ type: 'GREEN', label: 'Less Urgent', description: 'Minor injuries, non-critical', color: '#00e676', icon: Activity },
|
||||
{ type: 'WHITE', label: 'Non-Emergency', description: 'Scheduled transport, routine transfer', color: 'var(--text-secondary)', icon: User },
|
||||
{ type: 'BLACK', label: 'Dead', description: 'Deceased person, mortuary transport', color: '#9e9e9e', icon: Clock }
|
||||
];
|
||||
|
||||
const COMMON_SYMPTOMS = ['Fainting', 'Bleeding', 'Choking', 'Fits', 'Burns', 'Breathing Difficulty', 'Chest Pain', 'Head Injury' ];
|
||||
|
||||
const SEVERITY_LEVELS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
||||
const INCIDENT_CATEGORIES = ['MEDICAL', 'FIRE', 'ACCIDENT', 'TRAUMA', 'CARDIAC'];
|
||||
const GENDERS = ['Male', 'Female', 'Other'];
|
||||
|
||||
|
||||
const MOCK_DATA: BookingRecord[] = [
|
||||
{
|
||||
id: 'BK-1001',
|
||||
caller: 'John Doe',
|
||||
phone: '+91 98765 43210',
|
||||
triage: 'RED',
|
||||
pickupLocation: '221B Baker Street, London',
|
||||
patients: 1,
|
||||
symptoms: ['Chest Pain', 'Breathing Difficulty'],
|
||||
status: 'EN_ROUTE',
|
||||
timestamp: '2026-04-18 08:45',
|
||||
eta: '4 mins',
|
||||
receivedBy: 'Admin Sarah',
|
||||
fleetOperator: 'CureSelect North',
|
||||
assignedVehicle: 'AMB-702 (Paramedic Rajesh)',
|
||||
destinationHospital: 'City General Hospital'
|
||||
},
|
||||
{
|
||||
id: 'BK-1002',
|
||||
caller: 'Sarah Williams',
|
||||
phone: '+91 98888 77777',
|
||||
triage: 'ORANGE',
|
||||
pickupLocation: 'Central Square Mall, Floor 2',
|
||||
patients: 2,
|
||||
symptoms: ['Bleeding', 'Fracture'],
|
||||
status: 'DISPATCHED',
|
||||
timestamp: '2026-04-18 09:02',
|
||||
eta: '8 mins',
|
||||
receivedBy: 'Admin Mike',
|
||||
fleetOperator: 'Apollo Fleet',
|
||||
assignedVehicle: 'V-881 (Nurse Anita)',
|
||||
destinationHospital: 'Apollo Speciality Hub'
|
||||
},
|
||||
{
|
||||
id: 'BK-1003',
|
||||
caller: 'Unknown (Highway)',
|
||||
phone: '+91 95555 44444',
|
||||
triage: 'RED',
|
||||
pickupLocation: 'NH-45, Milestone 12',
|
||||
patients: 3,
|
||||
symptoms: ['Head Injury', 'Unconscious'],
|
||||
status: 'PENDING',
|
||||
timestamp: '2026-04-18 09:12',
|
||||
receivedBy: 'System Auto',
|
||||
fleetOperator: 'Govt Dispatch',
|
||||
assignedVehicle: 'PENDING',
|
||||
destinationHospital: 'Trauma Hub'
|
||||
}
|
||||
];
|
||||
|
||||
const TRIAGE_COLORS = {
|
||||
RED: { bg: 'rgba(255, 77, 77, 0.15)', text: '#ff4d4d', border: 'rgba(255, 77, 77, 0.3)', glow: '0 0 10px rgba(255, 77, 77, 0.1)' },
|
||||
ORANGE: { bg: 'rgba(255, 165, 0, 0.15)', text: '#ffa500', border: 'rgba(255, 165, 0, 0.3)', glow: '0 0 10px rgba(255, 165, 0, 0.1)' },
|
||||
GREEN: { bg: 'rgba(0, 230, 118, 0.15)', text: '#00e676', border: 'rgba(0, 230, 118, 0.3)', glow: '0 0 10px rgba(0, 230, 118, 0.1)' },
|
||||
WHITE: { bg: 'rgba(0, 0, 0, 0.02)', text: 'var(--text-primary)', border: 'var(--card-border)', glow: 'none' },
|
||||
BLACK: { bg: 'rgba(0, 0, 0, 0.05)', text: '#9e9e9e', border: 'var(--card-border)', glow: 'none' }
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
PENDING: '#ffa500',
|
||||
DISPATCHED: '#3B82F6',
|
||||
EN_ROUTE: '#3B82F6',
|
||||
ON_SITE: '#00e676',
|
||||
COMPLETED: '#9e9e9e',
|
||||
CANCELLED: '#ff4d4d'
|
||||
};
|
||||
|
||||
export const CallerPortal: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isBookingModalOpen, setIsBookingModalOpen] = useState(false);
|
||||
const [bookingStep, setBookingStep] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const [newBooking, setNewBooking] = useState<Partial<Incident>>({
|
||||
category: 'MEDICAL',
|
||||
triage_level: 'RED',
|
||||
severity: 'CRITICAL',
|
||||
organisationId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||||
gps_lat: 12.971598,
|
||||
gps_lon: 77.594566,
|
||||
address: '',
|
||||
guest_name: '',
|
||||
guest_phone: '',
|
||||
patients: [
|
||||
{
|
||||
name: '',
|
||||
age: 0,
|
||||
gender: 'Male',
|
||||
triage_level: 'RED',
|
||||
symptoms: []
|
||||
}
|
||||
],
|
||||
notes: ''
|
||||
});
|
||||
|
||||
// Persistence logic
|
||||
useEffect(() => {
|
||||
const savedBooking = localStorage.getItem('active_incident_booking');
|
||||
const savedStep = localStorage.getItem('active_incident_step');
|
||||
const savedModalState = localStorage.getItem('active_incident_modal_open');
|
||||
|
||||
if (savedBooking) setNewBooking(JSON.parse(savedBooking));
|
||||
if (savedStep) setBookingStep(parseInt(savedStep));
|
||||
if (savedModalState === 'true') setIsBookingModalOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBookingModalOpen) {
|
||||
localStorage.setItem('active_incident_booking', JSON.stringify(newBooking));
|
||||
localStorage.setItem('active_incident_step', bookingStep.toString());
|
||||
localStorage.setItem('active_incident_modal_open', 'true');
|
||||
} else {
|
||||
localStorage.removeItem('active_incident_booking');
|
||||
localStorage.removeItem('active_incident_step');
|
||||
localStorage.removeItem('active_incident_modal_open');
|
||||
}
|
||||
}, [newBooking, bookingStep, isBookingModalOpen]);
|
||||
|
||||
// Warning before leave
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isBookingModalOpen) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [isBookingModalOpen]);
|
||||
|
||||
|
||||
const handleNext = () => setBookingStep(s => s + 1);
|
||||
const handleBack = () => setBookingStep(s => s - 1);
|
||||
|
||||
const addPatient = () => {
|
||||
setNewBooking(prev => ({
|
||||
...prev,
|
||||
patients: [
|
||||
...(prev.patients || []),
|
||||
{ name: '', age: 0, gender: 'Male', triage_level: 'RED', symptoms: [] }
|
||||
]
|
||||
}));
|
||||
};
|
||||
|
||||
const removePatient = (index: number) => {
|
||||
setNewBooking(prev => ({
|
||||
...prev,
|
||||
patients: (prev.patients || []).filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePatient = (index: number, updates: Partial<Patient>) => {
|
||||
setNewBooking(prev => {
|
||||
const updatedPatients = [...(prev.patients || [])];
|
||||
updatedPatients[index] = { ...updatedPatients[index], ...updates };
|
||||
return { ...prev, patients: updatedPatients };
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSymptom = (patientIndex: number, symptomName: string) => {
|
||||
setNewBooking(prev => {
|
||||
const updatedPatients = [...(prev.patients || [])];
|
||||
const patient = updatedPatients[patientIndex];
|
||||
const exists = patient.symptoms.find(s => s.name === symptomName);
|
||||
|
||||
if (exists) {
|
||||
patient.symptoms = patient.symptoms.filter(s => s.name !== symptomName);
|
||||
} else {
|
||||
patient.symptoms = [...patient.symptoms, { name: symptomName, duration_minutes: 10 }];
|
||||
}
|
||||
|
||||
updatedPatients[patientIndex] = patient;
|
||||
return { ...prev, patients: updatedPatients };
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuthorizeDispatch = async () => {
|
||||
setIsLoading(true);
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
try {
|
||||
const response = await incidentsApi.createIncident(newBooking, token);
|
||||
if (response && (response.status === 200 || response.status === 201)) {
|
||||
setSuccessMessage("MISSION AUTHORIZED: DISPATCH SEQUENCE INITIATED");
|
||||
localStorage.removeItem('active_incident_booking');
|
||||
localStorage.removeItem('active_incident_step');
|
||||
localStorage.removeItem('active_incident_modal_open');
|
||||
setTimeout(() => {
|
||||
setIsBookingModalOpen(false);
|
||||
setBookingStep(1);
|
||||
setSuccessMessage(null);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Dispatch failed:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.5, staggerChildren: 0.1 } }
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, x: -10 },
|
||||
visible: { opacity: 1, x: 0 }
|
||||
};
|
||||
|
||||
const renderBookingStep = () => {
|
||||
if (successMessage) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
style={{ textAlign: 'center', padding: '40px 0' }}
|
||||
>
|
||||
<div style={{ width: '80px', height: '80px', background: 'rgba(0, 230, 118, 0.1)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px' }}>
|
||||
<CheckCircle2 size={48} color="#00e676" />
|
||||
</div>
|
||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 900, color: '#00e676', marginBottom: '12px' }}>{successMessage}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Transmitting coordinates to nearest tactical units...</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (bookingStep) {
|
||||
case 1:
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, marginBottom: '24px', letterSpacing: '-0.5px' }}>Severity Assessment</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '14px', marginBottom: '24px' }}>
|
||||
{CATEGORIES.map(cat => (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
key={cat.type}
|
||||
onClick={() => { setNewBooking({...newBooking, triage_level: cat.type}); }}
|
||||
className="glass"
|
||||
style={{
|
||||
padding: '20px', borderRadius: '16px', textAlign: 'left', cursor: 'pointer',
|
||||
border: `1px solid ${newBooking.triage_level === cat.type ? cat.color : 'var(--card-border)'}`,
|
||||
background: newBooking.triage_level === cat.type ? `${cat.color}15` : 'rgba(0,0,0,0.02)',
|
||||
display: 'flex', alignItems: 'center', gap: '18px',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '12px', background: `${cat.color}20`, borderRadius: '12px', color: cat.color,
|
||||
boxShadow: newBooking.triage_level === cat.type ? `0 0 15px ${cat.color}40` : 'none'
|
||||
}}>
|
||||
<cat.icon size={28} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 800, color: cat.color, fontSize: '1.1rem' }}>{cat.label}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{cat.description}</div>
|
||||
</div>
|
||||
{newBooking.triage_level === cat.type && <CheckCircle2 size={20} color={cat.color} />}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', display: 'block', marginBottom: '8px' }}>Category</label>
|
||||
<select
|
||||
value={newBooking.category}
|
||||
onChange={e => setNewBooking({...newBooking, category: e.target.value})}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '12px', borderRadius: '10px', border: '1px solid var(--card-border)', background: 'transparent', color: 'var(--text-primary)', fontWeight: 700 }}
|
||||
>
|
||||
{INCIDENT_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', display: 'block', marginBottom: '8px' }}>Severity</label>
|
||||
<select
|
||||
value={newBooking.severity}
|
||||
onChange={e => setNewBooking({...newBooking, severity: e.target.value})}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '12px', borderRadius: '10px', border: '1px solid var(--card-border)', background: 'transparent', color: 'var(--text-primary)', fontWeight: 700 }}
|
||||
>
|
||||
{SEVERITY_LEVELS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleNext} className="hover-glow" style={{ width: '100%', padding: '18px', borderRadius: '16px', background: '#3B82F6', border: 'none', color: '#fff', fontWeight: 900, cursor: 'pointer', fontSize: '1rem', marginTop: '24px' }}>NEXT: CALLER INFO</button>
|
||||
</motion.div>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, marginBottom: '24px' }}>Caller & Location</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<User size={18} style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.4 }} />
|
||||
<input
|
||||
placeholder="Guest Name"
|
||||
value={newBooking.guest_name || ''}
|
||||
onChange={e => setNewBooking({...newBooking, guest_name: e.target.value})}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '16px 16px 16px 48px', background: 'rgba(0,0,0,0.01)', border: '1px solid var(--card-border)', borderRadius: '12px', color: 'var(--text-primary)', fontSize: '0.9rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<PhoneCall size={18} style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.4 }} />
|
||||
<input
|
||||
placeholder="Guest Phone"
|
||||
value={newBooking.guest_phone || ''}
|
||||
onChange={e => setNewBooking({...newBooking, guest_phone: e.target.value})}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '16px 16px 16px 48px', background: 'rgba(0,0,0,0.01)', border: '1px solid var(--card-border)', borderRadius: '12px', color: 'var(--text-primary)', fontSize: '0.9rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<MapPin size={18} style={{ position: 'absolute', left: '16px', top: '18px', opacity: 0.4 }} />
|
||||
<textarea
|
||||
placeholder="Full Pickup Address"
|
||||
value={newBooking.address || ''}
|
||||
onChange={e => setNewBooking({...newBooking, address: e.target.value})}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '16px 16px 16px 48px', background: 'rgba(0,0,0,0.01)', border: '1px solid var(--card-border)', borderRadius: '12px', color: 'var(--text-primary)', fontSize: '0.9rem', minHeight: '80px', resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Navigation size={16} style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.4 }} />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Lat"
|
||||
value={newBooking.gps_lat}
|
||||
onChange={e => setNewBooking({...newBooking, gps_lat: parseFloat(e.target.value)})}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '12px 12px 12px 40px', background: 'rgba(0,0,0,0.01)', border: '1px solid var(--card-border)', borderRadius: '10px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Navigation size={16} style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', opacity: 0.4 }} />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Lon"
|
||||
value={newBooking.gps_lon}
|
||||
onChange={e => setNewBooking({...newBooking, gps_lon: parseFloat(e.target.value)})}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '12px 12px 12px 40px', background: 'rgba(0,0,0,0.01)', border: '1px solid var(--card-border)', borderRadius: '10px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '32px' }}>
|
||||
<button onClick={handleBack} className="glass" style={{ flex: 1, padding: '16px', borderRadius: '12px', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', cursor: 'pointer', fontWeight: 700 }}>BACK</button>
|
||||
<button onClick={handleNext} className="hover-glow" style={{ flex: 2, padding: '16px', borderRadius: '12px', background: '#3B82F6', border: 'none', color: '#fff', fontWeight: 800, cursor: 'pointer', fontSize: '1rem' }}>PATIENT DETAILS</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, margin: 0 }}>Patient Registry</h3>
|
||||
<button onClick={addPatient} style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid #3B82F6', color: '#3B82F6', padding: '6px 12px', borderRadius: '8px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Plus size={14} /> ADD PATIENT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', maxHeight: '400px', overflowY: 'auto', paddingRight: '4px' }} className="no-scrollbar">
|
||||
{newBooking.patients?.map((patient, idx) => (
|
||||
<div key={idx} className="glass" style={{ padding: '20px', borderRadius: '20px', border: '1px solid var(--card-border)', position: 'relative' }}>
|
||||
<button onClick={() => removePatient(idx)} style={{ position: 'absolute', top: '10px', right: '10px', background: 'none', border: 'none', color: '#ff4d4d', cursor: 'pointer', opacity: 0.6 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||
<div style={{ width: '24px', height: '24px', background: '#3B82F6', borderRadius: '50%', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.7rem', fontWeight: 900 }}>{idx + 1}</div>
|
||||
<span style={{ fontWeight: 800, fontSize: '0.9rem' }}>Patient Assessment</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<label style={{ fontSize: '0.65rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Full Name</label>
|
||||
<input
|
||||
placeholder="Patient Name"
|
||||
value={patient.name}
|
||||
onChange={e => updatePatient(idx, { name: e.target.value })}
|
||||
className="glass"
|
||||
style={{ padding: '10px', borderRadius: '8px', border: '1px solid var(--card-border)', background: 'rgba(0,0,0,0.01)', color: 'var(--text-primary)', fontSize: '0.85rem', width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<label style={{ fontSize: '0.65rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Age</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Age"
|
||||
value={patient.age || ''}
|
||||
onChange={e => updatePatient(idx, { age: parseInt(e.target.value) || 0 })}
|
||||
className="glass"
|
||||
style={{ padding: '10px', borderRadius: '8px', border: '1px solid var(--card-border)', background: 'rgba(0,0,0,0.01)', color: 'var(--text-primary)', fontSize: '0.85rem', width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<label style={{ fontSize: '0.65rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Gender</label>
|
||||
<select
|
||||
value={patient.gender}
|
||||
onChange={e => updatePatient(idx, { gender: e.target.value })}
|
||||
className="glass"
|
||||
style={{ padding: '10px', borderRadius: '8px', border: '1px solid var(--card-border)', background: 'transparent', color: 'var(--text-primary)', fontSize: '0.85rem', width: '100%' }}
|
||||
>
|
||||
{GENDERS.map(g => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '0.65rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', marginBottom: '8px', display: 'block' }}>Triage</label>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat.type}
|
||||
onClick={() => updatePatient(idx, { triage_level: cat.type })}
|
||||
style={{
|
||||
flex: 1, padding: '8px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 800, cursor: 'pointer',
|
||||
background: patient.triage_level === cat.type ? `${cat.color}20` : 'rgba(0,0,0,0.02)',
|
||||
border: `1px solid ${patient.triage_level === cat.type ? cat.color : 'var(--card-border)'}`,
|
||||
color: patient.triage_level === cat.type ? cat.color : 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
{cat.type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', marginBottom: '8px', display: 'block' }}>Symptoms</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{COMMON_SYMPTOMS.map(s => {
|
||||
const isSelected = patient.symptoms.some(sym => sym.name === s);
|
||||
return (
|
||||
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => toggleSymptom(idx, s)}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: '20px', fontSize: '0.7rem', fontWeight: 600, cursor: 'pointer',
|
||||
background: isSelected ? 'rgba(59, 130, 246, 0.1)' : 'rgba(0,0,0,0.02)',
|
||||
border: `1px solid ${isSelected ? '#3B82F6' : 'var(--card-border)'}`,
|
||||
color: isSelected ? '#3B82F6' : 'var(--text-secondary)',
|
||||
display: 'flex', alignItems: 'center', gap: '8px'
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
{isSelected && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', borderLeft: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '8px' }}>
|
||||
<Clock size={12} />
|
||||
<input
|
||||
type="number"
|
||||
value={patient.symptoms.find(sym => sym.name === s)?.duration_minutes || 10}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
updatePatient(idx, {
|
||||
symptoms: patient.symptoms.map(sym => sym.name === s ? { ...sym, duration_minutes: val } : sym)
|
||||
});
|
||||
}}
|
||||
style={{ width: '30px', background: 'transparent', border: 'none', color: '#3B82F6', fontSize: '0.7rem', fontWeight: 900, outline: 'none' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.6rem', opacity: 0.7 }}>m</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '32px' }}>
|
||||
<button onClick={handleBack} className="glass" style={{ flex: 1, padding: '16px', borderRadius: '12px', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', cursor: 'pointer', fontWeight: 700 }}>BACK</button>
|
||||
<button onClick={handleNext} className="hover-glow" style={{ flex: 2, padding: '16px', borderRadius: '12px', background: '#3B82F6', border: 'none', color: '#fff', fontWeight: 800, cursor: 'pointer', fontSize: '1rem' }}>MISSION REVIEW</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<h3 style={{ fontSize: '1.4rem', fontWeight: 800, marginBottom: '24px' }}>Finalize Mission</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginBottom: '24px' }}>
|
||||
<div className="glass" 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', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<Activity size={18} color="#3B82F6" />
|
||||
<span style={{ fontWeight: 800, fontSize: '0.9rem' }}>Mission Summary</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span>Category:</span> <span style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{newBooking.category}</span></div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span>Severity:</span> <span style={{ fontWeight: 700, color: '#ff4d4d' }}>{newBooking.severity}</span></div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span>Total Patients:</span> <span style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{newBooking.patients?.length}</span></div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}><span>Location:</span> <span style={{ fontWeight: 700, color: 'var(--text-primary)', maxWidth: '200px', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{newBooking.address}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', marginBottom: '8px', display: 'block' }}>Field Notes / Instructions</label>
|
||||
<textarea
|
||||
placeholder="Any additional info for responders..."
|
||||
value={newBooking.notes || ''}
|
||||
onChange={e => setNewBooking({...newBooking, notes: e.target.value})}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '16px', background: 'rgba(0,0,0,0.01)', border: '1px solid var(--card-border)', borderRadius: '12px', color: 'var(--text-primary)', fontSize: '0.9rem', minHeight: '100px', resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '32px' }}>
|
||||
<button onClick={handleBack} className="glass" style={{ flex: 1, padding: '16px', borderRadius: '12px', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', cursor: 'pointer', fontWeight: 700 }}>BACK</button>
|
||||
<button
|
||||
onClick={handleAuthorizeDispatch}
|
||||
disabled={isLoading}
|
||||
className="hover-glow pulse-glow"
|
||||
style={{
|
||||
flex: 2, padding: '16px', borderRadius: '12px', background: 'linear-gradient(135deg, #3B82F6, #1d4ed8)', border: 'none', color: '#fff', fontWeight: 900, cursor: 'pointer', fontSize: '1.1rem', letterSpacing: '0.5px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px', opacity: isLoading ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{isLoading ? <Clock size={20} className="spin" /> : <Zap size={20} />}
|
||||
{isLoading ? 'TRANSMITTING...' : 'AUTHORIZE DISPATCH'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="page-container no-scrollbar"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '32px', position: 'relative' }}
|
||||
>
|
||||
{/* Decorative Orbs */}
|
||||
<div style={{ position: 'absolute', top: -50, right: -50, width: '300px', height: '300px', background: 'radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div style={{ position: 'absolute', bottom: 50, left: -50, width: '400px', height: '400px', background: 'radial-gradient(circle, rgba(255, 77, 77, 0.05) 0%, transparent 70%)', pointerEvents: 'none', zIndex: 0 }} />
|
||||
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative', zIndex: 1 }}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ padding: '6px', background: '#3B82F6', borderRadius: '6px' }}><Signal size={18} color="#fff" /></div>
|
||||
<span style={{ fontSize: '0.9rem', fontWeight: 800, color: '#3B82F6', letterSpacing: '2px', textTransform: 'uppercase' }}>Live Command</span>
|
||||
</div>
|
||||
<h2 style={{ fontSize: '3rem', fontWeight: 900, background: 'linear-gradient(135deg, var(--text-primary) 0%, #3B82F6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', letterSpacing: '-2px', lineHeight: 1 }}>
|
||||
Fleet Intelligence Nodes
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginTop: '8px' }}>
|
||||
Real-time telemetry and resource orchestration for critical emergency services.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants} style={{ display: 'flex', gap: '14px' }}>
|
||||
<motion.button whileHover={{ scale: 1.05 }} className="glass" style={{ padding: '12px 24px', borderRadius: '14px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--card-border)', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontWeight: 700 }}>
|
||||
<Download size={20} /> ARCHIVE
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => setIsBookingModalOpen(true)}
|
||||
whileHover={{ scale: 1.05, boxShadow: '0 0 30px rgba(59, 130, 246, 0.5)' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="emergency-btn pulse-glow"
|
||||
style={{
|
||||
padding: '12px 32px', background: 'linear-gradient(135deg, #3B82F6 0%, #1d4ed8 100%)',
|
||||
border: 'none', borderRadius: '14px', color: '#fff', fontWeight: 900,
|
||||
display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer',
|
||||
fontSize: '1rem', letterSpacing: '0.5px'
|
||||
}}>
|
||||
<Plus size={24} strokeWidth={3} /> EMERGENCY MISSION
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</header>
|
||||
|
||||
{/* Modern Stats Layer */}
|
||||
<motion.div variants={containerVariants} style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '24px', position: 'relative', zIndex: 1 }}>
|
||||
{[
|
||||
{ label: 'Network Throughput', value: '142 Cases', icon: Activity, color: '#3B82F6', trend: '+12%' },
|
||||
{ label: 'Pending Dispatch', value: '03 Critical', icon: ShieldAlert, color: '#ff4d4d', trend: 'P0 Priority' },
|
||||
{ label: 'Active Coverage', value: '92.4%', icon: Navigation, color: '#00e676', trend: 'Optimal' },
|
||||
{ label: 'Operator Status', value: '12 Units', icon: UserCheck, color: 'var(--text-primary)', trend: 'Online' }
|
||||
].map((stat, i) => (
|
||||
<motion.div key={i} variants={itemVariants} whileHover={{ y: -5 }}>
|
||||
<Card style={{ padding: '24px', position: 'relative', overflow: 'hidden', borderLeft: `6px solid ${stat.color}` }}>
|
||||
<div style={{ position: 'absolute', right: '-10px', top: '-10px', opacity: 0.05 }}><stat.icon size={100} /></div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.75rem', textTransform: 'uppercase', fontWeight: 900, letterSpacing: '1px' }}>{stat.label}</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 900, marginTop: '12px', color: 'var(--text-primary)', letterSpacing: '-1px' }}>{stat.value}</div>
|
||||
<div style={{ marginTop: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ padding: '2px 8px', borderRadius: '10px', background: `${stat.color}15`, color: stat.color, fontSize: '0.7rem', fontWeight: 900 }}>{stat.trend}</div>
|
||||
<div style={{ flex: 1, height: '2px', background: 'rgba(0,0,0,0.05)', borderRadius: '1px' }}>
|
||||
<div style={{ width: '70%', height: '100%', background: stat.color, borderRadius: '1px', boxShadow: `0 0 10px ${stat.color}` }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Refined Command Grid */}
|
||||
<motion.div variants={itemVariants} style={{ position: 'relative', zIndex: 1 }}>
|
||||
<Card style={{ padding: '0', overflow: 'hidden', borderRadius: '24px', border: '1px solid var(--card-border)', background: 'var(--card-bg)' }}>
|
||||
<div style={{ padding: '24px', borderBottom: '1px solid rgba(0,0,0,0.02)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: '12px', margin: 0, fontSize: '1.2rem', fontWeight: 800 }}>
|
||||
<LayoutDashboard size={22} color="#3B82F6" />
|
||||
System Access Registry
|
||||
</h3>
|
||||
<div className="glass" style={{ padding: '8px 16px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<Search size={18} color="var(--text-secondary)" />
|
||||
<input placeholder="Filter missions..." style={{ background: 'transparent', border: 'none', color: 'var(--text-primary)', outline: 'none', fontSize: '0.9rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '1100px' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(0,0,0,0.01)' }}>
|
||||
<th style={{ padding: '20px 24px', fontSize: '0.75rem', color: 'var(--text-secondary)', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '1px' }}>ID & Mission Log</th>
|
||||
<th style={{ padding: '20px 24px', fontSize: '0.75rem', color: 'var(--text-secondary)', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '1px' }}>Caller Registry</th>
|
||||
<th style={{ padding: '20px 24px', fontSize: '0.75rem', color: 'var(--text-secondary)', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '1px' }}>Operational Node</th>
|
||||
<th style={{ padding: '20px 24px', fontSize: '0.75rem', color: 'var(--text-secondary)', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '1px' }}>Tactical Rescue</th>
|
||||
<th style={{ padding: '20px 24px', fontSize: '0.75rem', color: 'var(--text-secondary)', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '1px' }}>Logistics pathway</th>
|
||||
<th style={{ padding: '20px 24px', textAlign: 'right' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_DATA.map((row) => (
|
||||
<motion.tr
|
||||
key={row.id}
|
||||
whileHover={{ background: 'rgba(59, 130, 246, 0.02)' }}
|
||||
style={{ borderBottom: '1px solid rgba(0,0,0,0.02)', cursor: 'default', transition: 'background 0.3s' }}
|
||||
>
|
||||
<td style={{ padding: '20px 24px' }}>
|
||||
<div className="mono" style={{ fontWeight: 900, fontSize: '0.9rem', color: '#3B82F6' }}>{row.id}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.4 }}>
|
||||
<Clock size={12} />
|
||||
<span style={{ fontSize: '0.7rem' }}>{row.timestamp}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '20px 24px' }}>
|
||||
<div style={{ fontWeight: 800, fontSize: '1rem' }}>{row.caller}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '0.6rem', padding: '3px 8px', borderRadius: '6px', fontWeight: 900,
|
||||
background: TRIAGE_COLORS[row.triage].bg, color: TRIAGE_COLORS[row.triage].text, border: `1px solid ${TRIAGE_COLORS[row.triage].border}`,
|
||||
boxShadow: TRIAGE_COLORS[row.triage].glow
|
||||
}}>{row.triage}</span>
|
||||
<div style={{ width: '4px', height: '4px', borderRadius: '50%', background: 'rgba(0,0,0,0.1)' }} />
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 700, color: STATUS_COLORS[row.status] }}>{row.status.replace('_', ' ')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '20px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ width: '40px', height: '40px', background: 'rgba(59, 130, 246, 0.1)', borderRadius: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<UserCheck size={20} color="#3B82F6" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800 }}>{row.receivedBy}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', textTransform: 'uppercase', marginTop: '2px' }}>{row.fleetOperator}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '20px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ width: '40px', height: '40px', background: 'rgba(0, 230, 118, 0.1)', borderRadius: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Truck size={20} color="#00e676" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.95rem', fontWeight: 800 }}>{row.assignedVehicle}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '4px' }}>
|
||||
<div className="pulse" style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#00e676' }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#00e676', fontWeight: 700 }}>{row.eta ? `+ ${row.eta} ETA` : 'Active'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '20px 24px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '10px', background: 'rgba(0,0,0,0.01)', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#ff4d4d', boxShadow: '0 0 8px #ff4d4d' }} />
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)', maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.pickupLocation}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Hospital size={16} color="#3B82F6" />
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 800, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>{row.destinationHospital}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '20px 24px', textAlign: 'right' }}>
|
||||
<motion.button whileHover={{ x: 5 }} style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: '#3B82F6' }}>
|
||||
<ChevronRight size={24} />
|
||||
</motion.button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Modal - Re-designed for Attraction */}
|
||||
<AnimatePresence>
|
||||
{isBookingModalOpen && (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(255,255,255,0.85)', backdropFilter: 'blur(15px)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 30 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 30 }}
|
||||
className="glass"
|
||||
style={{
|
||||
width: '100%', maxWidth: '540px', background: 'var(--card-bg)', borderRadius: '32px', padding: '40px', border: '1px solid #3B82F6',
|
||||
boxShadow: '0 0 100px rgba(59, 130, 246, 0.05)', position: 'relative', overflowY: 'auto', maxHeight: '90vh'
|
||||
}}
|
||||
|
||||
>
|
||||
{/* Decorative Modal Background */}
|
||||
<div style={{ position: 'absolute', top: -100, left: -100, width: '300px', height: '300px', background: 'radial-gradient(circle, rgba(59, 130, 246, 0.05) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '40px', position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ width: '48px', height: '48px', borderRadius: '16px', background: 'rgba(59, 130, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Zap size={24} color="#3B82F6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.6rem', fontWeight: 900, margin: 0, letterSpacing: '-0.5px' }}>Mission Config</h2>
|
||||
<div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}>
|
||||
{[1, 2, 3, 4].map(i => <div key={i} style={{ width: '20px', height: '3px', borderRadius: '2px', background: i <= bookingStep ? '#3B82F6' : 'rgba(0,0,0,0.1)' }} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button whileHover={{ rotate: 90 }} onClick={() => { setIsBookingModalOpen(false); setBookingStep(1); }} style={{ background: 'rgba(0,0,0,0.03)', border: 'none', borderRadius: '50%', padding: '10px', cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||
<X size={24} />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
{renderBookingStep()}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<style>{`
|
||||
.pulse-glow {
|
||||
animation: pulseGlow 3s infinite;
|
||||
}
|
||||
@keyframes pulseGlow {
|
||||
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
|
||||
70% { box-shadow: 0 0 0 15px rgba(59, 130, 246, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
|
||||
}
|
||||
.pulse {
|
||||
animation: pulsePoint 2s infinite;
|
||||
}
|
||||
@keyframes pulsePoint {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.5); opacity: 0.5; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.page-container::-webkit-scrollbar { display: none; }
|
||||
`}</style>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
59
src/pages/ComingSoonPortal.tsx
Normal file
59
src/pages/ComingSoonPortal.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Construction, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface ComingSoonPortalProps {
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
export const ComingSoonPortal: React.FC<ComingSoonPortalProps> = ({ title, icon: Icon }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-white p-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-md w-full bg-slate-900/50 border border-slate-800 rounded-3xl p-12 text-center backdrop-blur-xl"
|
||||
>
|
||||
<div className="w-20 h-20 bg-blue-500/10 border border-blue-500/20 rounded-2xl flex items-center justify-center mx-auto mb-8 text-blue-400">
|
||||
<Icon size={40} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-black mb-4 tracking-tight">{title} Portal</h1>
|
||||
<div className="flex items-center justify-center gap-2 text-amber-500 font-bold text-sm uppercase tracking-widest mb-8">
|
||||
<Construction size={16} />
|
||||
<span>Under Construction</span>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-400 mb-10 leading-relaxed">
|
||||
The {title} specialized interface is currently being optimized for high-performance clinical workflows.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => navigate('/launcher')}
|
||||
className="w-full py-4 bg-white text-slate-950 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to Launcher
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="w-full py-4 bg-slate-800 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Access Core Dashboard
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-12 text-slate-600 text-xs font-mono">
|
||||
STUB_ID: {title.toUpperCase().replace(/\s/g, '_')}_v0.1-ALPHA
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
839
src/pages/Dashboard.tsx
Normal file
839
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Truck,
|
||||
HeartPulse,
|
||||
Video,
|
||||
ShieldCheck,
|
||||
Database,
|
||||
Settings,
|
||||
Users as UsersIcon,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
Navigation
|
||||
} from 'lucide-react';
|
||||
import { StatCard, Card } from '../components/Common';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell
|
||||
} from 'recharts';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { incidentsApi } from '../api/incidents';
|
||||
import { authApi } from '../api/auth';
|
||||
import type { Incident } from '../api/types';
|
||||
|
||||
// --- LIVE INCIDENT MAP COMPONENT ---
|
||||
const LiveIncidentMap: React.FC<{ incidents: Incident[] }> = ({ incidents }) => {
|
||||
const [isMapReady, setIsMapReady] = React.useState(false);
|
||||
const mapRef = React.useRef<any>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const markersRef = React.useRef<{ [key: string]: any }>({});
|
||||
const [L, setL] = React.useState<any>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Ensure Leaflet assets are loaded
|
||||
if (!document.getElementById('leaflet-style')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'leaflet-style';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const initMap = (leaflet: any) => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
// Defensive: Clear container if Leaflet previously left a trace but we lost the ref
|
||||
if ((containerRef.current as any)._leaflet_id) {
|
||||
containerRef.current.innerHTML = '';
|
||||
delete (containerRef.current as any)._leaflet_id;
|
||||
}
|
||||
|
||||
const m = leaflet.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false
|
||||
}).setView([12.9716, 77.5946], 11);
|
||||
|
||||
leaflet.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19
|
||||
}).addTo(m);
|
||||
|
||||
mapRef.current = m;
|
||||
m.whenReady(() => setIsMapReady(true));
|
||||
};
|
||||
|
||||
const existingL = (window as any).L;
|
||||
if (existingL) {
|
||||
setL(existingL);
|
||||
initMap(existingL);
|
||||
} else {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
const leaflet = (window as any).L;
|
||||
if (leaflet) {
|
||||
setL(leaflet);
|
||||
initMap(leaflet);
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
try {
|
||||
mapRef.current.remove();
|
||||
} catch (e) {
|
||||
console.warn('Error removing map:', e);
|
||||
}
|
||||
mapRef.current = null;
|
||||
setIsMapReady(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!L || !mapRef.current || !isMapReady) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
// Clear old markers that are no longer in the list
|
||||
Object.keys(markersRef.current).forEach(id => {
|
||||
if (!incidents.find(inc => inc.id === id)) {
|
||||
markersRef.current[id].remove();
|
||||
delete markersRef.current[id];
|
||||
}
|
||||
});
|
||||
|
||||
const coordMap: { [key: string]: number } = {};
|
||||
|
||||
incidents.forEach(inc => {
|
||||
let lat = Number(inc.gps_lat);
|
||||
let lon = Number(inc.gps_lon);
|
||||
|
||||
// Handle overlapping coordinates with deterministic jitter
|
||||
const coordKey = `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
||||
const count = (coordMap[coordKey] || 0) + 1;
|
||||
coordMap[coordKey] = count;
|
||||
|
||||
if (count > 1) {
|
||||
// Spiral jitter for better visibility of multiple incidents at same spot
|
||||
const radius = 0.0003 * Math.sqrt(count - 1);
|
||||
const angle = (count - 1) * 137.5 * (Math.PI / 180); // Golden angle
|
||||
lat += radius * Math.cos(angle);
|
||||
lon += radius * Math.sin(angle);
|
||||
}
|
||||
|
||||
let color = '#F59E0B'; // Default Amber for active
|
||||
const status = inc.status?.toUpperCase();
|
||||
|
||||
if (status === 'COMPLETED' || status === 'HANDOVER') {
|
||||
color = '#10B981'; // Green for resolved
|
||||
} else if (status === 'CANCELLED') {
|
||||
color = '#4A5568'; // Gray for cancelled
|
||||
} else if (inc.severity?.toUpperCase() === 'CRITICAL') {
|
||||
color = '#EF4444'; // Red for critical
|
||||
}
|
||||
|
||||
if (!markersRef.current[inc.id]) {
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-incident-marker',
|
||||
html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; box-shadow: 0 0 15px ${color}; border: 2px solid #fff; position: relative;">
|
||||
<div style="position: absolute; top: -4px; left: -4px; right: -4px; bottom: -4px; border-radius: 50%; border: 2px solid ${color}; animation: pulse-ring 2s infinite;"></div>
|
||||
</div>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
});
|
||||
|
||||
const callerName = inc.guest_name || inc.caller_id || 'Unknown';
|
||||
const callerPhone = inc.guest_phone || 'N/A';
|
||||
const patientCount = inc.patients?.length || 0;
|
||||
|
||||
markersRef.current[inc.id] = L.marker([lat, lon], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup(`
|
||||
<div style="padding: 12px; min-width: 220px; font-family: 'Inter', sans-serif; color: var(--text-primary);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<b style="color: var(--accent-cyan); font-size: 0.9rem; font-family: monospace;">#${inc.id.split('-').pop()?.toUpperCase()}</b>
|
||||
<span style="font-size: 0.6rem; background: ${color}33; color: ${color}; padding: 2px 8px; border-radius: 4px; font-weight: 900; border: 1px solid ${color}66;">${inc.status}</span>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.75rem; color: var(--text-primary); margin-bottom: 6px; font-weight: 700;">${inc.category} <span style="color: ${color}">• ${inc.severity}</span></div>
|
||||
|
||||
<div style="font-size: 0.7rem; color: var(--text-secondary); margin-bottom: 12px; display: flex; align-items: start; gap: 6px; line-height: 1.4;">
|
||||
<span style="font-size: 0.9rem;">📍</span> <span>${inc.address}</span>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.02); padding: 10px; border-radius: 8px; border: 1px solid var(--card-border);">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="font-size: 0.6rem; color: #64748B; text-transform: uppercase; font-weight: 700;">Caller</span>
|
||||
<span style="font-size: 0.7rem; font-weight: 700; color: var(--text-primary);">${callerName}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="font-size: 0.6rem; color: #64748B; text-transform: uppercase; font-weight: 700;">Phone</span>
|
||||
<span style="font-size: 0.7rem; font-weight: 700; color: var(--text-primary);">${callerPhone}</span>
|
||||
</div>
|
||||
<div style="padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.05); display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<div style="width: 6px; height: 6px; border-radius: 50%; background: var(--accent-cyan);"></div>
|
||||
<span style="font-size: 0.65rem; font-weight: 800; color: var(--accent-cyan);">${patientCount} PATIENT${patientCount !== 1 ? 'S' : ''} DETECTED</span>
|
||||
</div>
|
||||
<div style="font-size: 0.6rem; color: #94A3B8; padding-left: 12px; font-style: italic; overflow: hidden; text-overflow: ellipsis; display: flex; flex-direction: column; gap: 2px;">
|
||||
${inc.patients && inc.patients.length > 0 ? inc.patients.map((p: any) => `
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>${p.name || 'Anonymous'} (${p.age || '?'}${p.gender ? p.gender[0] : ''})</span>
|
||||
<span style="color: var(--accent-cyan)">${p.symptoms?.map((s: any) => typeof s === 'string' ? s : s.name).join(', ') || 'No symptoms'}</span>
|
||||
</div>
|
||||
`).join('') : 'No patient data'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`, {
|
||||
className: 'glass-popup',
|
||||
maxWidth: 300
|
||||
});
|
||||
} else {
|
||||
markersRef.current[inc.id].setLatLng([lat, lon]);
|
||||
}
|
||||
});
|
||||
// Auto-fit map to show all incidents
|
||||
if (incidents.length > 0 && map && isMapReady) {
|
||||
try {
|
||||
const validCoords = incidents
|
||||
.map(i => [Number(i.gps_lat), Number(i.gps_lon)])
|
||||
.filter(c =>
|
||||
Number.isFinite(c[0]) &&
|
||||
Number.isFinite(c[1]) &&
|
||||
Math.abs(c[0]) <= 90 &&
|
||||
Math.abs(c[1]) <= 180 &&
|
||||
(c[0] !== 0 || c[1] !== 0) // Ignore 0,0 placeholders
|
||||
);
|
||||
|
||||
if (validCoords.length > 0) {
|
||||
const bounds = L.latLngBounds(validCoords as any);
|
||||
const container = map.getContainer();
|
||||
|
||||
if (bounds.isValid() && container.offsetWidth > 0 && container.offsetHeight > 0) {
|
||||
// If all points are at the exact same location, fitBounds can sometimes fail or behave weirdly
|
||||
const sw = bounds.getSouthWest();
|
||||
const ne = bounds.getNorthEast();
|
||||
|
||||
if (sw.lat === ne.lat && sw.lng === ne.lng) {
|
||||
map.setView(sw, 14);
|
||||
} else {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Suppress specific "Bounds are not valid" error as we handle it defensively now
|
||||
if (!(err instanceof Error && err.message.includes('Bounds are not valid'))) {
|
||||
console.error('Map fitBounds error:', err);
|
||||
}
|
||||
}
|
||||
} else if (map && isMapReady) {
|
||||
// Default view if no incidents
|
||||
map.setView([12.9716, 77.5946], 11);
|
||||
}
|
||||
|
||||
map.on('click', (e: any) => {
|
||||
const { lat, lng } = e.latlng;
|
||||
// Emit a custom event or use a ref to open the modal from the parent
|
||||
const event = new CustomEvent('map-click-add', { detail: { lat, lng } });
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
}, [incidents, L, isMapReady]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} id="dashboard-heatmap-container" style={{ height: '450px', borderRadius: '12px', overflow: 'hidden', position: 'relative', border: '1px solid var(--card-border)' }}>
|
||||
{!L && <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-secondary)', background: 'rgba(0,0,0,0.02)' }}>INITIALIZING GLOBAL HEATMAP...</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- CUSTOM CHART COMPONENTS ---
|
||||
const CustomChartTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="glass" style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
border: `1px solid ${payload[0].payload.color}`,
|
||||
boxShadow: `0 8px 32px rgba(0,0,0,0.05), 0 0 20px ${payload[0].payload.color}11`,
|
||||
borderRadius: '12px',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: payload[0].payload.color, boxShadow: `0 0 8px ${payload[0].payload.color}` }}></div>
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{payload[0].name} Status</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 900, color: 'var(--text-primary)', display: 'flex', alignItems: 'baseline', gap: '4px' }}>
|
||||
{payload[0].value}
|
||||
<span style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', fontWeight: 600 }}>ASSETS</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||
|
||||
const user = useMemo(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('teleems_user');
|
||||
if (!stored || stored === 'undefined' || stored === 'null') return {};
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
const roles = Array.isArray(user.roles) ? user.roles : [];
|
||||
const isFleetOp = roles.includes('FLEET_OPERATOR');
|
||||
const orgName = user.metadata?.organization?.company_name || 'Fleet Operator';
|
||||
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [clickCoords, setClickCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [newIncident, setNewIncident] = useState({
|
||||
category: 'MEDICAL',
|
||||
severity: 'HIGH',
|
||||
address: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleMapClick = (e: any) => {
|
||||
setClickCoords(e.detail);
|
||||
setNewIncident(prev => ({ ...prev, address: `Lat: ${e.detail.lat.toFixed(4)}, Lon: ${e.detail.lng.toFixed(4)}` }));
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
window.addEventListener('map-click-add', handleMapClick);
|
||||
return () => window.removeEventListener('map-click-add', handleMapClick);
|
||||
}, []);
|
||||
|
||||
const handleQuickAdd = async () => {
|
||||
if (!token || !clickCoords) return;
|
||||
try {
|
||||
const payload = {
|
||||
...newIncident,
|
||||
gps_lat: clickCoords.lat,
|
||||
gps_lon: clickCoords.lng,
|
||||
patients: [{ name: 'Pending', age: 0, gender: 'Unknown', symptoms: [], triage_code: 'GREEN' }]
|
||||
};
|
||||
await incidentsApi.createIncident(payload, token);
|
||||
setIsAddModalOpen(false);
|
||||
fetchData(); // Refresh map
|
||||
} catch (error) {
|
||||
console.error('Failed to quick-add incident:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!token) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Use individual try-catches or settle all to ensure one failure doesn't block the rest
|
||||
const [incRes, userRes, auditRes] = await Promise.allSettled([
|
||||
incidentsApi.getIncidents({}, token),
|
||||
authApi.getUsers(token),
|
||||
authApi.getAuditLogs(token)
|
||||
]);
|
||||
|
||||
if (incRes.status === 'fulfilled' && incRes.value && incRes.value.data) {
|
||||
const processed = (incRes.value.data || []).map((inc: any) => ({
|
||||
...inc,
|
||||
gps_lat: Number(inc.gps_lat),
|
||||
gps_lon: Number(inc.gps_lon)
|
||||
}));
|
||||
setIncidents(processed);
|
||||
} else if (incRes.status === 'rejected') {
|
||||
console.error('Failed to fetch incidents:', incRes.reason);
|
||||
}
|
||||
|
||||
if (userRes.status === 'fulfilled' && userRes.value && userRes.value.data) {
|
||||
setUsers(Array.isArray(userRes.value.data) ? userRes.value.data : []);
|
||||
}
|
||||
|
||||
if (auditRes.status === 'fulfilled' && auditRes.value && !auditRes.value.status) {
|
||||
setAuditLogs(Array.isArray(auditRes.value.data) ? auditRes.value.data : (Array.isArray(auditRes.value.logs) ? auditRes.value.logs : []));
|
||||
} else {
|
||||
setAuditLogs([]);
|
||||
if (auditRes.status === 'rejected') {
|
||||
console.warn('Audit logs unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000); // Poll every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, [token]);
|
||||
|
||||
// Derived Metrics
|
||||
const activeIncidents = incidents.filter(i =>
|
||||
!['COMPLETED', 'CANCELLED', 'HANDOVER'].includes(i.status?.toUpperCase())
|
||||
);
|
||||
|
||||
const fleetOperators = users.filter((u: any) => {
|
||||
const r = Array.isArray(u.roles) ? u.roles.map((sr: any) => String(sr).toUpperCase()) : [];
|
||||
return r.includes('FLEET_OPERATOR') || r.includes('FLEET OPERATOR');
|
||||
});
|
||||
|
||||
const criticalIssues = incidents.filter(i => i.severity?.toUpperCase() === 'CRITICAL');
|
||||
|
||||
const fleetStatusData = [
|
||||
{ name: 'IDLE', value: users.filter(u => u.status === 'ACTIVE').length, color: '#3B82F6' },
|
||||
{ name: 'ACTIVE', value: activeIncidents.length, color: '#10B981' },
|
||||
{ name: 'ALERT', value: criticalIssues.length, color: '#EF4444' },
|
||||
{ name: 'OFFLINE', value: users.filter(u => u.status === 'INACTIVE').length || 2, color: '#4A5568' },
|
||||
];
|
||||
|
||||
const activityData = Array.isArray(auditLogs) ? auditLogs.slice(0, 7).reverse().map((log) => ({
|
||||
time: log.timestamp ? new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '--:--',
|
||||
count: Math.floor(Math.random() * 20) + 10 // Mocking activity intensity from audit density
|
||||
})) : [];
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '24px', paddingBottom: '40px' }}>
|
||||
{/* Header Section */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<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` : 'Super Admin Command Center'}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
|
||||
<span className="status-pulse" style={{ background: 'var(--accent-green)' }}></span>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
Live platform telemetry synchronized at {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<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={14} className={isLoading ? 'spin' : ''} /> REFRESH LIVE
|
||||
</button>
|
||||
<div className="glass mono" style={{ padding: '8px 16px', fontSize: '0.75rem', color: 'var(--accent-green)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<UsersIcon size={14} /> {fleetOperators.length} OPERATORS ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary Stats Bar */}
|
||||
<div className="stats-bar" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', marginBottom: 0 }}>
|
||||
<StatCard
|
||||
label="Active Incidents"
|
||||
value={activeIncidents.length}
|
||||
icon={Activity}
|
||||
glowColor="red"
|
||||
pulse={activeIncidents.length > 0}
|
||||
trend={{ value: '14%', isUp: true }}
|
||||
/>
|
||||
<StatCard
|
||||
label="Operational Fleet"
|
||||
value={fleetOperators.length}
|
||||
subValue={users.length.toString()}
|
||||
icon={Truck}
|
||||
glowColor="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
label="Dispatch SLA"
|
||||
value="1.4s"
|
||||
icon={Zap}
|
||||
glowColor="green"
|
||||
trend={{ value: '0.2s', isUp: false }}
|
||||
/>
|
||||
<StatCard
|
||||
label="Critical Cases"
|
||||
value={criticalIssues.length}
|
||||
icon={HeartPulse}
|
||||
glowColor="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Live CCE nodes"
|
||||
value={users.filter(u => u.roles?.includes('CCE')).length || 4}
|
||||
icon={Video}
|
||||
glowColor="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
label="Node Integrity"
|
||||
value="100%"
|
||||
icon={ShieldCheck}
|
||||
glowColor="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Operational Grid */}
|
||||
<div className="main-grid" style={{ gridTemplateColumns: '1.5fr 1fr 1fr', height: 'auto', alignItems: 'start' }}>
|
||||
{/* Real-time Heatmap */}
|
||||
<Card title="Global Incident Explorer" subtitle="System-wide incident history and live telemetry">
|
||||
<LiveIncidentMap incidents={incidents} />
|
||||
|
||||
{/* Legend Overlay (Absolute in Card) */}
|
||||
<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>
|
||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>CRITICAL</span>
|
||||
</div>
|
||||
<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>
|
||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>ACTIVE (L/M)</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#10B981', boxShadow: '0 0 10px #10B981' }}></div>
|
||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>RESOLVED</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 */}
|
||||
<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={{ height: '180px', position: 'relative', margin: '10px 0', minWidth: 0 }}>
|
||||
<ResponsiveContainer width="100%" height="180">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={fleetStatusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={8}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
animationBegin={0}
|
||||
animationDuration={1500}
|
||||
>
|
||||
{fleetStatusData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${entry.color}44)` }}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomChartTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Central Stat */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em' }}>Total</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 900, color: 'var(--text-primary)', lineHeight: 1 }}>
|
||||
{fleetStatusData.reduce((acc, curr) => acc + curr.value, 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.5rem', color: 'var(--accent-cyan)', fontWeight: 800, marginTop: '2px' }}>ASSETS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 => {
|
||||
const total = fleetStatusData.reduce((acc, curr) => acc + curr.value, 0);
|
||||
const percentage = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
||||
return (
|
||||
<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={{ width: '8px', height: '8px', background: item.color, borderRadius: '2px', boxShadow: `0 0 10px ${item.color}66` }}></div>
|
||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: 'var(--text-secondary)' }}>{item.name}</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-primary)' }}>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="System Performance" subtitle="Transaction density (30s avg)">
|
||||
<div style={{ height: '140px', minWidth: 0, position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={activityData}>
|
||||
<defs>
|
||||
<linearGradient id="colorSessions" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--accent-cyan)" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="var(--accent-cyan)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Tooltip
|
||||
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={2} fillOpacity={1} fill="url(#colorSessions)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform DNA Section */}
|
||||
<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.">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginTop: '8px' }}>
|
||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Database size={22} color="var(--accent-cyan)" />
|
||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-green)' }}>SYNCED</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Master Data</div>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>482 Triage rules active.</p>
|
||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||
<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 className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<ShieldCheck size={22} color="var(--accent-green)" />
|
||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-green)' }}>100%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Compliance</div>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>HIPAA / ABDM verified.</p>
|
||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||
<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 className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Settings size={22} color="var(--warning-amber)" />
|
||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--warning-amber)' }}>STABLE</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>System Logic</div>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>SLA thresholds active.</p>
|
||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||
<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 className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Navigation size={22} color="var(--accent-cyan)" />
|
||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-cyan)' }}>ACTIVE</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Network Hub</div>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>Multi-zone sync active.</p>
|
||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card title="Critical Task Cluster" style={{ background: 'rgba(255, 59, 59, 0.03)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{[
|
||||
{ label: 'Blood Link', 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: 'Police V-Link', status: 'Healthy', color: 'var(--accent-green)' },
|
||||
].map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '8px', borderBottom: '1px solid rgba(0,0,0,0.02)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 700 }}>{r.label}</div>
|
||||
<div style={{ fontSize: '0.6rem', color: r.color }}>{r.status}</div>
|
||||
</div>
|
||||
<div className="status-pulse" style={{ background: r.color }}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* SLA Ticker & Progress */}
|
||||
<div style={{ display: 'flex', gap: '24px', alignItems: 'stretch', flexWrap: 'wrap' }}>
|
||||
<Card style={{ flex: 1, padding: '16px 24px' }}>
|
||||
<div style={{ display: 'grid', gap: '16px', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))' }}>
|
||||
{[
|
||||
{ label: 'Foundation', progress: 100 },
|
||||
{ label: 'MVP Core', progress: 100 },
|
||||
{ label: 'Clinical AI', progress: 85 },
|
||||
{ label: 'Fleet Intel', progress: 42 },
|
||||
{ label: 'Compliance', progress: 100 },
|
||||
].map((phase, i) => (
|
||||
<div key={phase.label} style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.65rem', marginBottom: '6px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{phase.label}</span>
|
||||
<span className="mono">{phase.progress}%</span>
|
||||
</div>
|
||||
<div style={{ height: '4px', background: 'rgba(0,0,0,0.02)', borderRadius: '2px', overflow: 'hidden' }}>
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${phase.progress}%` }}
|
||||
transition={{ duration: 1, delay: i * 0.1 }}
|
||||
style={{
|
||||
height: '100%',
|
||||
background: phase.progress === 100 ? 'var(--accent-green)' : 'var(--accent-cyan)',
|
||||
boxShadow: `0 0 10px ${phase.progress === 100 ? 'rgba(0,255,136,0.3)' : 'rgba(0,212,255,0.3)'}`
|
||||
}}
|
||||
></motion.div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="glass" style={{
|
||||
minWidth: '300px',
|
||||
flex: '1 1 320px',
|
||||
padding: '16px',
|
||||
border: '1px solid var(--card-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<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.75rem', color: 'var(--text-secondary)', overflow: 'hidden', flex: 1, position: 'relative' }}>
|
||||
<motion.div
|
||||
animate={{ x: [400, -800] }}
|
||||
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
||||
style={{ display: 'inline-block', whiteSpace: 'nowrap', fontWeight: 700 }}
|
||||
>
|
||||
HIPAA COMPLIANT ✅ | ABDM SYNCED ✅ | ISO 27001 AUDIT PASSED ✅ | PHI ENCRYPTED ✅ | DPDP ACT ALIGNED ✅ | END-TO-END TLS 1.3 ACTIVE ✅
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Add Modal */}
|
||||
<AnimatePresence>
|
||||
{isAddModalOpen && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(10px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 }}>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className="glass glow-cyan" style={{ width: '400px', padding: '24px', background: 'var(--base-bg)', borderRadius: '20px', border: '1px solid var(--card-border)' }}>
|
||||
<h3 style={{ fontSize: '1.2rem', fontWeight: 800, marginBottom: '20px', color: 'var(--accent-cyan)' }}>QUICK LOG INCIDENT</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase' }}>Coordinates</label>
|
||||
<div className="glass mono" style={{ padding: '10px', marginTop: '4px', fontSize: '0.8rem', background: 'rgba(0,0,0,0.01)' }}>
|
||||
{clickCoords?.lat.toFixed(6)}, {clickCoords?.lng.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase' }}>Category</label>
|
||||
<select
|
||||
value={newIncident.category}
|
||||
onChange={(e) => setNewIncident({ ...newIncident, category: e.target.value })}
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '10px', marginTop: '4px', background: 'rgba(0,0,0,0.03)', color: 'var(--text-primary)', border: '1px solid var(--card-border)', borderRadius: '8px' }}
|
||||
>
|
||||
<option value="MEDICAL">MEDICAL</option>
|
||||
<option value="TRAUMA">TRAUMA</option>
|
||||
<option value="CARDIAC">CARDIAC</option>
|
||||
<option value="ACCIDENT">ACCIDENT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase' }}>Severity</label>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '6px' }}>
|
||||
{['LOW', 'HIGH', 'CRITICAL'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setNewIncident({ ...newIncident, severity: s })}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
background: newIncident.severity === s ? (s === 'CRITICAL' ? 'var(--alert-red)' : 'var(--accent-cyan)') : 'rgba(0,0,0,0.03)',
|
||||
color: newIncident.severity === s ? '#fff' : 'var(--text-secondary)',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase' }}>Notes / Address Info</label>
|
||||
<textarea
|
||||
value={newIncident.notes}
|
||||
onChange={(e) => setNewIncident({ ...newIncident, notes: e.target.value, address: e.target.value || `Lat: ${clickCoords?.lat.toFixed(4)}` })}
|
||||
placeholder="Enter scene details..."
|
||||
className="glass"
|
||||
style={{ width: '100%', padding: '10px', marginTop: '4px', height: '80px', background: 'rgba(0,0,0,0.03)', color: 'var(--text-primary)', border: '1px solid var(--card-border)', borderRadius: '8px', resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button onClick={() => setIsAddModalOpen(false)} className="glass" style={{ flex: 1, padding: '12px', borderRadius: '10px', cursor: 'pointer', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', fontWeight: 700 }}>CANCEL</button>
|
||||
<button onClick={handleQuickAdd} className="glow-cyan" style={{ flex: 1, padding: '12px', borderRadius: '10px', cursor: 'pointer', background: 'var(--accent-cyan)', color: '#000', border: 'none', fontWeight: 800 }}>LOG MISSION</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
1063
src/pages/FleetDispatch.css
Normal file
1063
src/pages/FleetDispatch.css
Normal file
File diff suppressed because it is too large
Load Diff
3290
src/pages/FleetDispatch.tsx
Normal file
3290
src/pages/FleetDispatch.tsx
Normal file
File diff suppressed because it is too large
Load Diff
296
src/pages/FleetLogin.tsx
Normal file
296
src/pages/FleetLogin.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, NavLink } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Truck,
|
||||
Zap,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
User,
|
||||
ArrowRight,
|
||||
Cpu,
|
||||
Radio,
|
||||
Activity,
|
||||
KeyRound,
|
||||
ShieldAlert,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Crosshair,
|
||||
Signal
|
||||
} from 'lucide-react';
|
||||
import { authApi } from '../api/auth';
|
||||
import './Login.css'; // Reuse core login styles but we'll override some for the tactical look
|
||||
|
||||
export const FleetLogin = () => {
|
||||
const [username, setUsername] = useState('fleet_operator');
|
||||
const [password, setPassword] = useState('Fleet@123');
|
||||
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 navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setShowError('');
|
||||
|
||||
// --- MOCK LOGIN FOR FLEET OPERATOR ---
|
||||
if (username === 'fleet_operator' && password === 'Fleet@123') {
|
||||
setTimeout(() => {
|
||||
localStorage.setItem('teleems_auth', 'true');
|
||||
localStorage.setItem('teleems_token', 'mock-fleet-token-2026');
|
||||
localStorage.setItem('teleems_user', JSON.stringify({
|
||||
id: 'fleet-op-001',
|
||||
username: 'fleet_operator',
|
||||
roles: ['FLEET_OPERATOR'],
|
||||
metadata: {
|
||||
organization: { company_name: 'TeleEMS Fleet Services' }
|
||||
}
|
||||
}));
|
||||
setIsLoading(false);
|
||||
navigate('/fleet-operator');
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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_token', response.data.access_token || '');
|
||||
localStorage.setItem('teleems_user', JSON.stringify(response.data.user || {}));
|
||||
navigate('/fleet-operator');
|
||||
}
|
||||
} else {
|
||||
setShowError(response.message || 'Access Denied: Invalid Credentials');
|
||||
}
|
||||
} catch (err) {
|
||||
setShowError('Tactical Network Unavailable: Check Connection');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setShowError('');
|
||||
|
||||
try {
|
||||
const response = await authApi.verifyMfa(mfaSessionToken, mfaCode);
|
||||
|
||||
if (response.status === 201 || response.status === 200) {
|
||||
localStorage.setItem('teleems_auth', 'true');
|
||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
||||
const userToStore = response.data.user || tempUser || {};
|
||||
userToStore.mfa_enabled = true;
|
||||
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
||||
navigate('/fleet-operator');
|
||||
} else {
|
||||
setShowError('Invalid Security Token');
|
||||
}
|
||||
} catch (err) {
|
||||
setShowError('Token Verification Failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page fleet-login-theme" style={{ background: '#020617' }}>
|
||||
{/* 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 */}
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
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' }}
|
||||
>
|
||||
<div style={{ position: 'absolute', top: '0', left: '50%', width: '2px', height: '100%', background: 'linear-gradient(to bottom, rgba(59, 130, 246, 0.2), transparent)' }} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
key={loginStep}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5, ease: "circOut" }}
|
||||
className="login-card glass"
|
||||
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">
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="login-logo"
|
||||
style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)' }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{loginStep === 'login' ? (
|
||||
<form onSubmit={handleLogin} className="login-form">
|
||||
<div className="input-group">
|
||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>Operator ID</label>
|
||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
<User className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
||||
<input
|
||||
type="text"
|
||||
className="login-input mono"
|
||||
placeholder="ID_ENTRY"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
style={{ color: '#fff' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>Command Key</label>
|
||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
<Lock className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="login-input mono"
|
||||
placeholder="KEY_REQUIRED"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ color: '#fff' }}
|
||||
required
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer', paddingRight: '12px' }}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="fleet-login-submit"
|
||||
type="submit"
|
||||
className="login-button"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
background: 'var(--accent-cyan)',
|
||||
color: '#000',
|
||||
fontWeight: 900,
|
||||
boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)'
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Cpu className="spin" size={20} />
|
||||
) : (
|
||||
<>
|
||||
INITIALIZE SESSION
|
||||
<ArrowRight size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleMfaVerify} className="login-form">
|
||||
<div className="input-group">
|
||||
<label className="input-label" style={{ color: 'var(--accent-cyan)', opacity: 0.6 }}>TOTP Authorization</label>
|
||||
<div className="input-wrapper" style={{ background: 'rgba(0, 0, 0, 0.3)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
<KeyRound className="input-icon" size={18} style={{ color: 'var(--accent-cyan)' }} />
|
||||
<input
|
||||
type="text"
|
||||
className="login-input mono"
|
||||
placeholder="000 000"
|
||||
maxLength={6}
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||
style={{ color: '#fff' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
disabled={isLoading}
|
||||
style={{ background: 'var(--accent-cyan)', color: '#000', fontWeight: 900 }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Cpu className="spin" size={20} />
|
||||
) : (
|
||||
<>
|
||||
VERIFY IDENTITY
|
||||
<ShieldCheck size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{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)' }}>
|
||||
<ShieldAlert size={14} />
|
||||
<span>{showError}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="security-badge" style={{ borderColor: 'rgba(59, 130, 246, 0.2)', background: 'rgba(59, 130, 246, 0.05)' }}>
|
||||
<Signal size={14} color="var(--accent-cyan)" />
|
||||
<span style={{ color: 'var(--accent-cyan)', fontWeight: 700 }}>SECURE UPLINK ESTABLISHED</span>
|
||||
</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>
|
||||
</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 className="login-sys-log" style={{ bottom: '40px', left: '40px', opacity: 0.3 }}>
|
||||
<p>TERMINAL_ID: DISPATCH-X7</p>
|
||||
<p>PROTOCOL: CS-SECURE-v4</p>
|
||||
<p>ENCRYPTION: QUANTUM-SAFE</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
395
src/pages/FleetOperatorDashboard.tsx
Normal file
395
src/pages/FleetOperatorDashboard.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Activity,
|
||||
Truck,
|
||||
Zap,
|
||||
ShieldCheck,
|
||||
MapPin,
|
||||
Clock,
|
||||
Navigation,
|
||||
AlertTriangle,
|
||||
Fuel,
|
||||
Gauge,
|
||||
Thermometer,
|
||||
Wind,
|
||||
Bell,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
Route as RouteIcon,
|
||||
Users,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
ShoppingCart
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } 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 { FleetAssets } from './fleet/FleetAssets';
|
||||
import { FleetPersonnel } from './fleet/FleetPersonnel';
|
||||
import { FleetInventory } from './fleet/FleetInventory';
|
||||
import { FleetScheduling } from './fleet/FleetScheduling';
|
||||
|
||||
// --- MOCK DATA FOR THE ENHANCED FEEL ---
|
||||
const MOCK_VEHICLES = [
|
||||
{ id: 'V001', number: 'TN-01-AM-1024', status: 'EN_ROUTE', speed: 45, fuel: 82, lat: 13.0827, lng: 80.2707, type: 'ALS' },
|
||||
{ id: 'V002', number: 'TN-05-AM-5521', status: 'IDLE', speed: 0, fuel: 65, lat: 13.0067, lng: 80.2575, type: 'BLS' },
|
||||
{ id: 'V003', number: 'TN-07-AM-1122', status: 'TRANSPORTING', speed: 52, fuel: 45, lat: 12.9667, lng: 80.2475, type: 'TRANSFER' },
|
||||
{ id: 'V004', number: 'TN-09-AM-9988', status: 'AT_SCENE', speed: 0, fuel: 78, lat: 12.9941, lng: 80.1709, type: 'AIR' },
|
||||
];
|
||||
|
||||
const PERFORMANCE_DATA = [
|
||||
{ time: '08:00', trips: 12, response: 14 },
|
||||
{ time: '10:00', trips: 18, response: 12 },
|
||||
{ time: '12:00', trips: 25, response: 18 },
|
||||
{ time: '14:00', trips: 22, response: 15 },
|
||||
{ time: '16:00', trips: 30, response: 22 },
|
||||
{ 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 = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get('tab') || 'overview';
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
const [vehicles, setVehicles] = useState(MOCK_VEHICLES);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
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':
|
||||
return <FleetAssets />;
|
||||
case 'personnel':
|
||||
return <FleetPersonnel />;
|
||||
case 'scheduling':
|
||||
return <FleetScheduling />;
|
||||
case 'inventory':
|
||||
return <FleetInventory />;
|
||||
case 'analytics':
|
||||
return <div className="glass" style={{ padding: '40px', textAlign: 'center', color: 'var(--text-secondary)' }}>Fleet Intelligence Reports Loading...</div>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fleet-operator-dashboard" style={{
|
||||
background: '#020617',
|
||||
color: '#F8FAFC',
|
||||
minHeight: '100vh',
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{/* Main Content Area */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '32px',
|
||||
maxWidth: '100%'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '32px' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '2.5rem', fontWeight: 900, letterSpacing: '-0.05em', color: 'var(--accent-cyan)', textTransform: 'uppercase' }}>
|
||||
{menuItems.find(m => m.id === activeTab)?.label || 'Fleet Command'}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', opacity: 0.7 }}>
|
||||
<div className="status-pulse" style={{ background: 'var(--accent-green)' }}></div>
|
||||
<span style={{ fontSize: '0.875rem' }}>Strategic Operations • Live Platform Telemetry</span>
|
||||
</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>
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1775
src/pages/HospitalConsole.css
Normal file
1775
src/pages/HospitalConsole.css
Normal file
File diff suppressed because it is too large
Load Diff
1156
src/pages/HospitalConsole.tsx
Normal file
1156
src/pages/HospitalConsole.tsx
Normal file
File diff suppressed because it is too large
Load Diff
893
src/pages/HospitalsNetwork.tsx
Normal file
893
src/pages/HospitalsNetwork.tsx
Normal file
@@ -0,0 +1,893 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Hospital,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Phone,
|
||||
Settings,
|
||||
CheckCircle2,
|
||||
MoreVertical,
|
||||
Shield,
|
||||
User as UserIcon,
|
||||
Navigation2,
|
||||
XCircle,
|
||||
Stethoscope,
|
||||
Lock,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../components/Common';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { authApi } from '../api/auth';
|
||||
import { incidentsApi } from '../api/incidents';
|
||||
|
||||
|
||||
|
||||
|
||||
type ViewMode = 'NETWORK_OVERVIEW' | 'HOSPITAL_MGMT' | 'APPROVAL_QUEUE' | 'ANALYTICS';
|
||||
|
||||
export const HospitalsNetwork: React.FC = () => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('NETWORK_OVERVIEW');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [realHospitals, setRealHospitals] = useState<any[]>([]);
|
||||
const [incidents, setIncidents] = useState<any[]>([]);
|
||||
const [issues, setIssues] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [editingHospital, setEditingHospital] = useState<any | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loadIncidents = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
if (!token) return;
|
||||
const res = await incidentsApi.getIncidents({ limit: 10 }, token);
|
||||
if (res && res.data) {
|
||||
setIncidents(res.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load incidents:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadHospitals = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
if (!token) return;
|
||||
|
||||
const response = await authApi.getUsers(token);
|
||||
|
||||
if (response && (response.status === 401 || response.message?.toLowerCase().includes('expired'))) {
|
||||
localStorage.removeItem('teleems_auth');
|
||||
localStorage.removeItem('teleems_token');
|
||||
localStorage.removeItem('teleems_user');
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && response.data) {
|
||||
// Robust Hospital Node extraction
|
||||
const filteredAdmins = response.data.filter((u: any) => {
|
||||
const roles = Array.isArray(u.roles) ? u.roles.map((r: any) => String(r).toUpperCase()) : [];
|
||||
return roles.includes('HOSPITAL ADMIN') || roles.includes('HOSPITAL_ADMIN');
|
||||
});
|
||||
|
||||
const hospitalNodes = filteredAdmins.map((u: any) => {
|
||||
const metaHosp = u.metadata?.hospital || {};
|
||||
const metaOrg = u.metadata?.organization || {};
|
||||
|
||||
// Determine activity status
|
||||
const activeInc = incidents.filter(i => i.hospital_id === u.id && i.status !== 'RESOLVED').length;
|
||||
const [available] = (metaHosp.beds || '0/0').split('/').map((n: string) => parseInt(n) || 0);
|
||||
|
||||
let activityStatus = 'IDLE';
|
||||
if (activeInc > 5) activityStatus = 'CRITICAL LOAD';
|
||||
else if (activeInc > 0) activityStatus = `HANDLING ${activeInc} INCIDENTS`;
|
||||
else if (available < 5) activityStatus = 'NEAR CAPACITY';
|
||||
|
||||
return {
|
||||
id: u.id,
|
||||
name: metaHosp.name || metaOrg.company_name || u.name || u.username || 'Unknown Hospital',
|
||||
type: metaHosp.type || metaHosp.specialization || 'Multi-Specialty',
|
||||
beds: metaHosp.beds || '15/60',
|
||||
status: u.status || 'ACTIVE',
|
||||
activity: activityStatus,
|
||||
accreditation: metaHosp.accreditation || 'NABH',
|
||||
admin: u.name || u.username,
|
||||
phone: u.phone || 'Contact Support',
|
||||
email: u.email,
|
||||
city: metaHosp.city || metaOrg.city || 'Chennai',
|
||||
radius: metaHosp.radius || '15km',
|
||||
zones: [metaHosp.city || metaOrg.city || 'Chennai'],
|
||||
rawMetadata: u.metadata,
|
||||
roles: u.roles || []
|
||||
};
|
||||
});
|
||||
|
||||
setRealHospitals(hospitalNodes);
|
||||
|
||||
// Derive current issues
|
||||
const newIssues = hospitalNodes.map(h => {
|
||||
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 < 5) return { type: 'WARNING', msg: `${h.name}: Low bed availability`, hospital: h.name };
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
setIssues(newIssues as any[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hospitals:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadHospitals();
|
||||
loadIncidents();
|
||||
const interval = setInterval(() => {
|
||||
loadHospitals();
|
||||
loadIncidents();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
|
||||
const hospitalStats = useMemo(() => {
|
||||
return realHospitals.map(h => {
|
||||
const [available, total] = (h.beds || '0/0').split('/').map((n: string) => parseInt(n) || 0);
|
||||
return {
|
||||
name: h.name,
|
||||
total: total || 100,
|
||||
available: available || 0
|
||||
};
|
||||
});
|
||||
}, [realHospitals]);
|
||||
|
||||
const handleHospitalSubmit = async (data: any) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
if (!token) {
|
||||
throw new Error('No authentication token found. Please login again.');
|
||||
}
|
||||
|
||||
const result = await authApi.registerUser(data, token);
|
||||
|
||||
|
||||
if (result.error || result.status === 401) {
|
||||
throw new Error(result.error?.message || result.message || 'Unauthorized');
|
||||
}
|
||||
|
||||
console.log('Hospital Registration Success:', result);
|
||||
alert('Hospital registered successfully!');
|
||||
setIsModalOpen(false);
|
||||
loadHospitals();
|
||||
} catch (error: any) {
|
||||
console.error('Registration failed:', error);
|
||||
const isExpired = error.message.includes('expired');
|
||||
alert(`Registration failed: ${error.message}${isExpired ? '. Your session has expired, redirecting to login...' : ''}`);
|
||||
|
||||
if (isExpired) {
|
||||
localStorage.removeItem('teleems_auth');
|
||||
localStorage.removeItem('teleems_token');
|
||||
localStorage.removeItem('teleems_user');
|
||||
navigate('/login');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusToggle = async (hospital: any) => {
|
||||
try {
|
||||
const newStatus = hospital.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
|
||||
const payload = {
|
||||
name: hospital.admin,
|
||||
email: hospital.email,
|
||||
phone: hospital.phone || '',
|
||||
status: newStatus,
|
||||
role: 'HOSPITAL_ADMIN',
|
||||
metadata: hospital.rawMetadata
|
||||
};
|
||||
|
||||
const res = await authApi.updateUser(hospital.id, payload, token);
|
||||
if (res.status === 401) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
loadHospitals();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditHospitalSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingHospital) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
const payload = {
|
||||
name: editingHospital.admin,
|
||||
email: editingHospital.email,
|
||||
phone: editingHospital.phone || '',
|
||||
status: editingHospital.status,
|
||||
role: 'HOSPITAL_ADMIN',
|
||||
metadata: {
|
||||
...editingHospital.rawMetadata,
|
||||
hospital: {
|
||||
...editingHospital.rawMetadata?.hospital,
|
||||
name: editingHospital.name,
|
||||
city: editingHospital.city
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const res = await authApi.updateUser(editingHospital.id, payload, token);
|
||||
if (res.status === 401) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingHospital(null);
|
||||
loadHospitals();
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSubmit = () => {
|
||||
const form = document.getElementById('hospital-reg-form') as HTMLFormElement;
|
||||
if (form) form.requestSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ padding: '0 40px 40px 40px' }}>
|
||||
<header className="network-header-premium" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '2.5rem', fontWeight: 900, background: 'linear-gradient(90deg, var(--accent-blue), var(--text-primary))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', letterSpacing: '-1.5px' }}>
|
||||
Hospital Governance
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginTop: '4px', fontWeight: 700, letterSpacing: '0.5px' }}>
|
||||
NETWORK OPERATIONAL CONTROL • {realHospitals.length} ACTIVE NODES
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass" style={{ padding: '6px', borderRadius: '12px', display: 'flex', gap: '4px', background: 'rgba(0,0,0,0.03)' }}>
|
||||
<button
|
||||
onClick={() => setViewMode('NETWORK_OVERVIEW')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '8px', border: 'none',
|
||||
background: viewMode === 'NETWORK_OVERVIEW' ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: viewMode === 'NETWORK_OVERVIEW' ? '#fff' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem'
|
||||
}}>
|
||||
<Shield size={16} /> NETWORK OVERVIEW
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('HOSPITAL_MGMT')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '8px', border: 'none',
|
||||
background: viewMode === 'HOSPITAL_MGMT' ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: viewMode === 'HOSPITAL_MGMT' ? '#fff' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem'
|
||||
}}>
|
||||
<Hospital size={16} /> ACCOUNTS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('APPROVAL_QUEUE')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '8px', border: 'none',
|
||||
background: viewMode === 'APPROVAL_QUEUE' ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: viewMode === 'APPROVAL_QUEUE' ? '#fff' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem'
|
||||
}}>
|
||||
<CheckCircle2 size={16} /> APPROVALS <span style={{ background: 'var(--alert-red)', color: '#fff', fontSize: '0.6rem', padding: '2px 6px', borderRadius: '10px' }}>2</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('ANALYTICS')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '8px', border: 'none',
|
||||
background: viewMode === 'ANALYTICS' ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: viewMode === 'ANALYTICS' ? '#fff' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem'
|
||||
}}>
|
||||
<Stethoscope size={16} /> REPORTS
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'NETWORK_OVERVIEW' && (
|
||||
<motion.div key="overview" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
|
||||
{/* NETWORK PULSE STRIP */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '20px' }}>
|
||||
<Card style={{ padding: '20px', background: 'rgba(59, 130, 246, 0.03)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', textTransform: 'uppercase', fontWeight: 800 }}>Live Incidents</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginTop: '8px' }}>
|
||||
<div style={{ fontSize: '2.5rem', fontWeight: 900, color: 'var(--accent-cyan)' }}>{incidents.filter(i => i.status !== 'RESOLVED').length}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--accent-green)', fontWeight: 700 }}>ACTIVE</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ padding: '20px', background: 'rgba(255, 82, 82, 0.03)', border: '1px solid rgba(255, 82, 82, 0.2)' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', textTransform: 'uppercase', fontWeight: 800 }}>Critical Issues</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginTop: '8px' }}>
|
||||
<div style={{ fontSize: '2.5rem', fontWeight: 900, color: 'var(--alert-red)' }}>{issues.filter(i => i.type === 'CRITICAL').length}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--alert-red)', fontWeight: 700 }}>ALERTS</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ padding: '20px' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', textTransform: 'uppercase', fontWeight: 800 }}>Network Sync</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginTop: '8px' }}>
|
||||
<div style={{ fontSize: '2.5rem', fontWeight: 900, color: 'var(--text-primary)' }}>98%</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--accent-cyan)', fontWeight: 700 }}>STABLE</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ padding: '20px' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', textTransform: 'uppercase', fontWeight: 800 }}>System Health</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginTop: '12px' }}>
|
||||
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: 'var(--accent-green)', boxShadow: '0 0 10px var(--accent-green)' }}></div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 800 }}>OPTIMAL</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 2fr) 1fr', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ fontSize: '1.2rem', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Hospital size={20} color="var(--accent-cyan)" /> HOSPITAL NODES
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '20px' }}>
|
||||
{realHospitals.length === 0 && !isLoading && (
|
||||
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
|
||||
No hospital nodes registered in network.
|
||||
</div>
|
||||
)}
|
||||
{realHospitals.map((h) => (
|
||||
<Card key={h.id} className="hover-glow" style={{ padding: '20px', border: issues.some(i => i.hospital === h.name && i.type === 'CRITICAL') ? '1px solid var(--alert-red)' : '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ width: '44px', height: '44px', background: 'rgba(59, 130, 246, 0.1)', borderRadius: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Hospital size={22} color="var(--accent-cyan)" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.65rem', fontWeight: 800, color: h.status === 'ACTIVE' ? 'var(--accent-green)' : 'var(--alert-red)', textTransform: 'uppercase' }}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: h.status === 'ACTIVE' ? 'var(--accent-green)' : 'var(--alert-red)' }} />
|
||||
{h.status}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', fontWeight: 700 }}>{h.activity}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 800 }}>{h.name}</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '6px' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: 'var(--accent-cyan)', fontWeight: 700 }}>{h.type}</span>
|
||||
<span style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>• {h.city}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '24px', borderTop: '1px solid var(--card-border)', paddingTop: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Available Beds</div>
|
||||
<div className="mono" style={{ fontSize: '1rem', fontWeight: 800, color: h.beds.startsWith('0') ? 'var(--alert-red)' : 'var(--accent-green)' }}>{h.beds}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Active Cases</div>
|
||||
<div className="mono" style={{ fontSize: '1rem', fontWeight: 800, color: 'var(--text-primary)' }}>{incidents.filter(i => i.hospital_id === h.id && i.status !== 'RESOLVED').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<Card title="CRITICAL ISSUES" subtitle="Real-time network alerts" style={{ border: '1px solid rgba(255, 82, 82, 0.3)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '15px' }}>
|
||||
{issues.length === 0 ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>No critical issues detected.</div>
|
||||
) : (
|
||||
issues.map((issue, idx) => (
|
||||
<div key={idx} style={{ padding: '12px', background: issue.type === 'CRITICAL' ? 'rgba(255, 82, 82, 0.1)' : 'rgba(255, 183, 77, 0.1)', borderLeft: `4px solid ${issue.type === 'CRITICAL' ? 'var(--alert-red)' : 'var(--warning-amber)'}`, borderRadius: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<AlertCircle size={14} color={issue.type === 'CRITICAL' ? 'var(--alert-red)' : 'var(--warning-amber)'} />
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: issue.type === 'CRITICAL' ? 'var(--alert-red)' : 'var(--warning-amber)' }}>{issue.type}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>{issue.msg}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', marginTop: '4px' }}>Identified 2m ago</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="LIVE ACTIVITY" subtitle="Latest incidents across network">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', marginTop: '15px' }}>
|
||||
{incidents.length === 0 ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>Monitoring network traffic...</div>
|
||||
) : (
|
||||
incidents.slice(0, 5).map((inc, i) => (
|
||||
<div key={i} style={{ paddingBottom: '12px', borderBottom: '1px solid var(--card-border)', display: 'flex', gap: '12px' }}>
|
||||
<div style={{ width: '32px', height: '32px', background: 'rgba(0,0,0,0.03)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Navigation2 size={16} color="var(--accent-cyan)" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{inc.patient_name || 'Emergency Call'}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>{inc.status.toUpperCase()} • {inc.priority || 'P1'}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--accent-cyan)', marginTop: '4px' }}>{new Date(inc.created_at).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{incidents.length > 5 && (
|
||||
<button style={{ width: '100%', padding: '10px', background: 'transparent', border: 'none', color: 'var(--accent-cyan)', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer', marginTop: '10px' }}>VIEW ALL ACTIVITY</button>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||
<Card title="Regional Bed Capacity" subtitle="Real-time availability by hospital node">
|
||||
<div style={{ height: '300px', marginTop: '20px' }}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={hospitalStats} layout="vertical">
|
||||
<XAxis type="number" hide />
|
||||
<YAxis dataKey="name" type="category" width={100} stroke="var(--text-secondary)" fontSize={11} tick={{fontWeight: 600}} />
|
||||
<Tooltip cursor={{fill: 'rgba(0,0,0,0.02)'}} contentStyle={{ background: 'var(--base-bg)', border: '1px solid var(--card-border)', borderRadius: '8px' }} />
|
||||
<Bar dataKey="total" fill="rgba(0,0,0,0.03)" radius={[0, 4, 4, 0]} barSize={20} />
|
||||
<Bar dataKey="available" fill="var(--accent-cyan)" radius={[0, 4, 4, 0]} barSize={20} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="HMIS Data Exchange Health" subtitle="Integration status of hospital information systems">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{[
|
||||
{ name: 'Apollo Main', api: 'REST/FHIR', status: 'Healthy', latency: '45ms', lastSync: '12s ago' },
|
||||
{ name: 'MGM Healthcare', api: 'REST/FHIR', status: 'Healthy', latency: '38ms', lastSync: '5s ago' },
|
||||
{ name: 'MIOT Int.', api: 'REST/FHIR', status: 'Healthy', latency: '42ms', lastSync: '8s ago' },
|
||||
{ name: 'Global Health', api: 'HL7v2', status: 'Syncing', latency: '120ms', lastSync: '1m ago' },
|
||||
{ name: 'Stanley Medical', api: 'Direct SQL', status: 'Critical', latency: '--', lastSync: '14h ago' },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px', background: 'rgba(0,0,0,0.02)', borderRadius: '10px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: item.status === 'Healthy' ? 'var(--accent-green)' : (item.status === 'Syncing' ? 'var(--warning-amber)' : 'var(--alert-red)') }} />
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{item.name}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>{item.api} Protocol</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="mono" style={{ fontSize: '0.75rem', color: 'var(--accent-cyan)' }}>{item.latency}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>{item.lastSync}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === 'HOSPITAL_MGMT' && (
|
||||
<HospitalManagement
|
||||
key="management"
|
||||
hospitals={realHospitals}
|
||||
onRegister={() => setIsModalOpen(true)}
|
||||
onEdit={(h) => setEditingHospital(h)}
|
||||
onToggleStatus={handleStatusToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'APPROVAL_QUEUE' && (
|
||||
<motion.div key="approvals" initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<Card title="Pending Hospital Registrations" subtitle="Review and approve new hospital node requests.">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--card-border)' }}>
|
||||
<th style={{ padding: '16px' }}>Request Details</th>
|
||||
<th style={{ padding: '16px' }}>Admin Info</th>
|
||||
<th style={{ padding: '16px' }}>Submitted</th>
|
||||
<th style={{ padding: '16px', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ id: 'REQ-9901', name: 'Global Health City', city: 'Chennai', admin: 'Dr. S. Karthik', email: 'sk@globalhealth.com', date: '2026-04-16' },
|
||||
{ id: 'REQ-9905', name: 'Fortis Malar', city: 'Chennai', admin: 'Pavitra M.', email: 'p.malar@fortis.com', date: '2026-04-15' }
|
||||
].map(req => (
|
||||
<tr key={req.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.02)' }}>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontWeight: 700 }}>{req.name}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{req.city} Node</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontSize: '0.85rem' }}>{req.admin}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--accent-cyan)' }}>{req.email}</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px', fontSize: '0.85rem' }}>{req.date}</td>
|
||||
<td style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<button style={{ padding: '8px 16px', background: 'var(--accent-green)', color: '#fff', border: 'none', borderRadius: '4px', fontWeight: 700, cursor: 'pointer' }}>APPROVE</button>
|
||||
<button style={{ padding: '8px 16px', background: 'rgba(0,0,0,0.02)', color: 'var(--alert-red)', border: '1px solid var(--alert-red)', borderRadius: '4px', fontWeight: 700, cursor: 'pointer' }}>REJECT</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === 'ANALYTICS' && (
|
||||
<motion.div key="analytics" initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '24px' }}>
|
||||
<Card title="Total Incidents Handled" subtitle="Last 30 days total volume">
|
||||
<div style={{ fontSize: '3rem', fontWeight: 900, color: 'var(--accent-cyan)', margin: '20px 0' }}>1,248</div>
|
||||
<div style={{ color: 'var(--accent-green)', fontWeight: 700 }}>↑ 14% vs last month</div>
|
||||
</Card>
|
||||
<Card title="Avg Handover Time" subtitle="Ambulance arrival to ED handoff">
|
||||
<div style={{ fontSize: '3rem', fontWeight: 900, color: 'var(--text-primary)', margin: '20px 0' }}>12.4m</div>
|
||||
<div style={{ color: 'var(--accent-green)', fontWeight: 700 }}>-1.2m improvement</div>
|
||||
</Card>
|
||||
<Card title="Clinical Escalation Rate" subtitle="TeleLink sessions requiring specialist">
|
||||
<div style={{ fontSize: '3rem', fontWeight: 900, color: 'var(--alert-red)', margin: '20px 0' }}>8.2%</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontWeight: 700 }}>Target: < 10%</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Network Volume Trends">
|
||||
<div style={{ height: '300px', display: 'flex', alignItems: 'flex-end', gap: '10px', padding: '40px 0' }}>
|
||||
{[40, 65, 52, 88, 70, 95, 82, 60, 75, 90, 110, 85].map((h, i) => (
|
||||
<div key={i} style={{ flex: 1, background: 'var(--accent-cyan)', height: `${h}%`, borderRadius: '4px 4px 0 0', opacity: 0.6 + (h/200), position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: '-25px', left: '50%', transform: 'translateX(-50%)', fontSize: '0.6rem', fontWeight: 900 }}>{h}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: 'var(--text-secondary)', marginTop: '10px' }}>
|
||||
<span>APR 01</span>
|
||||
<span>APR 07</span>
|
||||
<span>APR 14</span>
|
||||
<span>APR 21</span>
|
||||
<span>APR 28</span>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
title="Register New Hospital"
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={triggerSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
<HospitalRegistrationForm onSubmit={handleHospitalSubmit} loading={isSubmitting} />
|
||||
</Modal>
|
||||
|
||||
{/* EDIT HOSPITAL MODAL */}
|
||||
<Modal
|
||||
isOpen={!!editingHospital}
|
||||
title={`Edit ${editingHospital?.name}`}
|
||||
onClose={() => setEditingHospital(null)}
|
||||
onSubmit={() => {
|
||||
const form = document.getElementById('hospital-edit-form') as HTMLFormElement;
|
||||
if (form) form.requestSubmit();
|
||||
}}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{editingHospital && (
|
||||
<form id="hospital-edit-form" onSubmit={handleEditHospitalSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--accent-cyan)', marginBottom: '8px', fontWeight: 700 }}>HOSPITAL FULL NAME</label>
|
||||
<input name="name" type="text" required value={editingHospital.name} onChange={(e) => setEditingHospital({...editingHospital, name: e.target.value})} style={{ width: '100%', padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--accent-cyan)', marginBottom: '8px', fontWeight: 700 }}>PRIMARY CITY</label>
|
||||
<input name="city" type="text" required value={editingHospital.city} onChange={(e) => setEditingHospital({...editingHospital, city: e.target.value})} style={{ width: '100%', padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--accent-cyan)', marginBottom: '8px', fontWeight: 700 }}>ADMINISTRATOR NAME</label>
|
||||
<input name="admin" type="text" required value={editingHospital.admin} onChange={(e) => setEditingHospital({...editingHospital, admin: e.target.value})} style={{ width: '100%', padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--accent-cyan)', marginBottom: '8px', fontWeight: 700 }}>CONTACT PHONE</label>
|
||||
<input name="phone" type="text" required value={editingHospital.phone} onChange={(e) => setEditingHospital({...editingHospital, phone: e.target.value})} style={{ width: '100%', padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--accent-cyan)', marginBottom: '8px', fontWeight: 700 }}>OFFICIAL EMAIL</label>
|
||||
<input name="email" type="email" required value={editingHospital.email} onChange={(e) => setEditingHospital({...editingHospital, email: e.target.value})} style={{ width: '100%', padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 5.3 Hospital Management (CRUD Sub-page)
|
||||
const HospitalManagement: React.FC<{
|
||||
hospitals: any[];
|
||||
onRegister: () => void;
|
||||
onEdit: (h: any) => void;
|
||||
onToggleStatus: (h: any) => void;
|
||||
}> = ({ hospitals, onRegister, onEdit, onToggleStatus }) => {
|
||||
const filteredHospitals = hospitals; // realHospitals already filtered in loadHospitals for Admine role
|
||||
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700 }}>
|
||||
<Settings size={24} color="var(--accent-cyan)" /> Hospital Account Management
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button onClick={onRegister} className="glass" style={{ padding: '10px 20px', background: 'var(--accent-cyan)', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<Plus size={18} /> REGISTER NEW HOSPITAL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(0,0,0,0.02)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '16px' }}>Hospital Details</th>
|
||||
<th style={{ padding: '16px' }}>Leadership & Contact</th>
|
||||
<th style={{ padding: '16px' }}>Service Area</th>
|
||||
<th style={{ padding: '16px' }}>Accreditation</th>
|
||||
<th style={{ padding: '16px' }}>Status</th>
|
||||
<th style={{ padding: '16px', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredHospitals.map((h, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.02)' }}>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontWeight: 800 }}>{h.name}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--accent-cyan)', marginTop: '2px', fontWeight: 700 }}>{h.type.toUpperCase()}</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<UserIcon size={14} color="var(--text-secondary)" />
|
||||
<span>{h.admin}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
|
||||
<Phone size={14} color="var(--text-secondary)" />
|
||||
<span>{h.phone}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Navigation2 size={14} color="var(--accent-cyan)" />
|
||||
<span className="mono" style={{ fontWeight: 700 }}>{h.radius}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
||||
{h.zones.slice(0, 2).join(', ')}{h.zones.length > 2 ? '...' : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{ fontSize: '0.7rem', padding: '3px 8px', background: 'rgba(0,0,0,0.02)', borderRadius: '4px', border: '1px solid var(--card-border)' }}>{h.acc}</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: h.status === 'ACTIVE' ? 'var(--accent-green)' : 'var(--alert-red)', fontWeight: 800 }}>
|
||||
{h.status === 'ACTIVE' ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
|
||||
{h.status}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => onEdit(h)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer' }}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggleStatus(h)}
|
||||
style={{
|
||||
padding: '6px 12px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)',
|
||||
borderRadius: '4px', fontSize: '0.65rem', color: h.status === 'ACTIVE' ? 'var(--alert-red)' : 'var(--accent-green)',
|
||||
fontWeight: 800, cursor: 'pointer'
|
||||
}}>
|
||||
{h.status === 'ACTIVE' ? 'DEACTIVATE' : 'ACTIVATE'}
|
||||
</button>
|
||||
<button style={{ background: 'transparent', border: 'none', color: 'var(--alert-red)', cursor: 'pointer' }}>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(400px, 1fr) 400px', gap: '24px' }}>
|
||||
<Card title="Teleconsult Routing Rules">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{[
|
||||
{ trigger: 'Major Cardiac (Red)', target: 'Cath Lab / Cardiac Centre', priority: 'P0' },
|
||||
{ trigger: 'Severe Burns (Red)', target: 'Burns Specialty Unit', priority: 'P0' },
|
||||
{ trigger: 'Pediatric Emergency', target: 'Pediatric ED Node', priority: 'P1' },
|
||||
].map((rule, i) => (
|
||||
<div key={i} style={{ padding: '16px', background: 'rgba(0,0,0,0.01)', border: '1px solid var(--card-border)', borderRadius: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Stethoscope size={20} color="var(--accent-cyan)" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700 }}>{rule.trigger}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Route to: {rule.target}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ padding: '4px 8px', background: 'rgba(59, 130, 246, 0.1)', color: 'var(--accent-cyan)', fontWeight: 800, borderRadius: '4px' }}>{rule.priority}</div>
|
||||
</div>
|
||||
))}
|
||||
<button style={{ padding: '12px', background: 'transparent', border: '1px dashed var(--card-border)', color: 'var(--text-secondary)', borderRadius: '10px', fontSize: '0.8rem', cursor: 'pointer' }}>
|
||||
+ DEFINE NEW ROUTING RULE
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Accreditation Compliance">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>NABH Verification</span>
|
||||
<div style={{ color: 'var(--accent-green)', fontWeight: 700, fontSize: '0.75rem' }}>VALID</div>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '4px', background: 'rgba(0,0,0,0.02)', borderRadius: '2px' }}>
|
||||
<div style={{ width: '92%', height: '100%', background: 'var(--accent-green)', borderRadius: '2px' }}></div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '8px' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Quality Assurance Audit</span>
|
||||
<div style={{ color: 'var(--warning-amber)', fontWeight: 700, fontSize: '0.75rem' }}>PENDING</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
|
||||
Automatic compliance checks run every 30 days. Last verification was successful for 88% of the hospital network.
|
||||
</p>
|
||||
<button style={{ marginTop: '10px', padding: '10px', background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)', color: 'var(--accent-cyan)', borderRadius: '8px', fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer' }}>
|
||||
RUN FULL COMPLIANCE SYNC
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- COMPONENTS ---
|
||||
|
||||
const Modal: React.FC<{ isOpen: boolean; title: string; onClose: () => void; children: React.ReactNode; onSubmit?: () => void; loading?: boolean }> = ({ isOpen, title, onClose, children, onSubmit, loading }) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(255,255,255,0.85)', backdropFilter: 'blur(8px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px' }}>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="glass" style={{ width: '100%', maxWidth: '800px', maxHeight: '90vh', overflowY: 'auto', background: 'var(--card-bg)', padding: '32px', borderRadius: '20px', border: '1px solid var(--accent-cyan)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 800 }}>{title}</h3>
|
||||
<button onClick={onClose} style={{ background: 'transparent', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }}><XCircle size={24} /></button>
|
||||
</div>
|
||||
{children}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '16px', marginTop: '32px' }}>
|
||||
<button onClick={onClose} style={{ padding: '10px 20px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', color: 'var(--text-primary)', borderRadius: '6px', fontWeight: 700, cursor: 'pointer' }} disabled={loading}>CANCEL</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
style={{ padding: '10px 20px', background: 'var(--accent-cyan)', border: 'none', color: '#fff', borderRadius: '6px', fontWeight: 700, cursor: 'pointer', opacity: loading ? 0.7 : 1 }}
|
||||
>
|
||||
{loading ? 'PROCESSING...' : 'REGISTER HOSPITAL'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HospitalRegistrationForm: React.FC<{ onSubmit: (data: any) => void }> = ({ onSubmit }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
admin_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
hospital_name: '',
|
||||
city: '',
|
||||
lat: '13.0827',
|
||||
lon: '80.2707'
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
role: "HOSPITAL_ADMIN",
|
||||
name: formData.admin_name,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
metadata: {
|
||||
hospital: {
|
||||
name: formData.hospital_name,
|
||||
city: formData.city,
|
||||
lat: parseFloat(formData.lat),
|
||||
lon: parseFloat(formData.lon)
|
||||
}
|
||||
}
|
||||
};
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<form id="hospital-reg-form" onSubmit={handleFormSubmit} style={{ display: 'flex', flexWrap: 'wrap', gap: '20px' }}>
|
||||
<h4 style={{ width: '100%', color: 'var(--accent-cyan)', borderBottom: '1px solid rgba(59, 130, 246, 0.1)', paddingBottom: '8px' }}>ADMINISTRATOR DETAILS</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: '1 1 200px' }}>
|
||||
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Full Name</label>
|
||||
<input name="admin_name" value={formData.admin_name} onChange={handleChange} required placeholder="Dr. Administrator Name" className="glass" style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: '1 1 200px' }}>
|
||||
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Email Address</label>
|
||||
<input name="email" value={formData.email} onChange={handleChange} required type="email" placeholder="hospital@example.com" autoComplete="new-password" className="glass" style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: '1 1 200px' }}>
|
||||
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Phone Number</label>
|
||||
<input name="phone" value={formData.phone} onChange={handleChange} required placeholder="9876543210" className="glass" style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: '1 1 200px' }}>
|
||||
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Access Password</label>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<input name="password" value={formData.password} onChange={handleChange} required type={showPassword ? "text" : "password"} autoComplete="new-password" placeholder="••••••••" className="glass" style={{ padding: '10px 14px', paddingLeft: '36px', paddingRight: '40px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', outline: 'none', fontSize: '0.85rem', width: '100%' }} />
|
||||
<Lock size={14} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)' }} />
|
||||
<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>
|
||||
|
||||
<h4 style={{ width: '100%', color: 'var(--accent-cyan)', borderBottom: '1px solid rgba(59, 130, 246, 0.1)', paddingBottom: '8px', marginTop: '10px' }}>HOSPITAL PROFILE</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: '1 1 100%' }}>
|
||||
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Hospital Name</label>
|
||||
<input name="hospital_name" value={formData.hospital_name} onChange={handleChange} required placeholder="Apollo Hospital Chennai" className="glass" style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: '1 1 200px' }}>
|
||||
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>City</label>
|
||||
<input name="city" value={formData.city} onChange={handleChange} required placeholder="Chennai" className="glass" style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: '1 1 200px' }}>
|
||||
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Latitude</label>
|
||||
<input name="lat" value={formData.lat} onChange={handleChange} required type="number" step="0.0001" className="glass" style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: '1 1 200px' }}>
|
||||
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Longitude</label>
|
||||
<input name="lon" value={formData.lon} onChange={handleChange} required type="number" step="0.0001" className="glass" style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
1385
src/pages/LiveIncidents.tsx
Normal file
1385
src/pages/LiveIncidents.tsx
Normal file
File diff suppressed because it is too large
Load Diff
398
src/pages/Login.css
Normal file
398
src/pages/Login.css
Normal file
@@ -0,0 +1,398 @@
|
||||
/* ─── LOGIN PAGE SHELL ─────────────────────────────────────────────────────── */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #F8FAFC;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Decorative grid background */
|
||||
.login-grid-decor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 50% 0%, rgba(59, 130, 246, 0.03) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at 50% 100%, rgba(192, 132, 252, 0.03) 0%, transparent 55%),
|
||||
linear-gradient(rgba(59, 130, 246, 0.015) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.015) 1px, transparent 1px);
|
||||
background-size: 100% 100%, 100% 100%, 44px 44px, 44px 44px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Scanline effect */
|
||||
.scanline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.04) 2px,
|
||||
rgba(0, 0, 0, 0.04) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Radial vignette overlay */
|
||||
.login-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 30%, rgba(0, 0, 0, 0.03) 100%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── LOGIN CARD ───────────────────────────────────────────────────────────── */
|
||||
.login-card {
|
||||
width: 440px;
|
||||
max-width: calc(100vw - 40px);
|
||||
padding: 44px 40px 36px;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(40px) saturate(1.5);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 28px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(59, 130, 246, 0.03),
|
||||
0 30px 80px rgba(0, 0, 0, 0.08),
|
||||
0 0 60px rgba(59, 130, 246, 0.02);
|
||||
}
|
||||
|
||||
/* Ambient top highlight */
|
||||
.login-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 20%; right: 20%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.5), transparent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ─── HEADER ───────────────────────────────────────────────────────────────── */
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.logo-icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
box-shadow: 0 0 25px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.logo-icon-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||
border-radius: 17px;
|
||||
animation: pulse-ring 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 0.1; transform: scale(1.08); }
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #1E293B;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2.5px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── FORM ─────────────────────────────────────────────────────────────────── */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #475569;
|
||||
transition: color 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 13px 14px 13px 44px;
|
||||
color: #1E293B;
|
||||
font-family: 'Outfit', 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08), 0 0 20px rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.login-input:focus ~ .input-icon,
|
||||
.input-wrapper:focus-within .input-icon {
|
||||
color: rgba(59, 130, 246, 0.8);
|
||||
}
|
||||
|
||||
/* ─── EXTRAS ROW ───────────────────────────────────────────────────────────── */
|
||||
.login-extras {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12.5px;
|
||||
margin-bottom: 24px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Custom checkbox */
|
||||
.remember-me input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remember-me input[type="checkbox"]:checked {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-color: rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
|
||||
.remember-me input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
background: var(--accent-cyan, #3B82F6);
|
||||
border-radius: 2px;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: rgba(59, 130, 246, 0.8);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
/* ─── SUBMIT BUTTON ────────────────────────────────────────────────────────── */
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
background: #3B82F6;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 8px 30px rgba(59, 130, 246, 0.25), 0 0 0 1px rgba(59, 130, 246, 0.3);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 40px rgba(59, 130, 246, 0.4), 0 0 0 1px rgba(59, 130, 246, 0.4);
|
||||
background: #60A5FA;
|
||||
}
|
||||
|
||||
.login-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── SECURITY BADGE ───────────────────────────────────────────────────────── */
|
||||
.security-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 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.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ─── FOOTER ───────────────────────────────────────────────────────────────── */
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
margin-top: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.login-footer a {
|
||||
color: rgba(59, 130, 246, 0.85);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.login-footer a:hover {
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
/* ─── BG DECORATIVE TEXT (bottom-left) ────────────────────────────────────── */
|
||||
.login-sys-log {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
left: 36px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.login-sys-log p {
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #3B82F6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── STATUS INDICATORS (bottom-right of page, NOT card) ──────────────────── */
|
||||
.login-status-indicators {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
right: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
opacity: 0.25;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: status-blink 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes status-blink {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 6px currentColor; }
|
||||
50% { opacity: 0.4; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* ─── RESPONSIVE ───────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 36px 24px 28px;
|
||||
border-radius: 20px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.login-title { font-size: 22px; }
|
||||
.login-subtitle { font-size: 10px; letter-spacing: 1.5px; }
|
||||
.login-sys-log, .login-status-indicators { display: none; }
|
||||
}
|
||||
318
src/pages/Login.tsx
Normal file
318
src/pages/Login.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, NavLink } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
User,
|
||||
ArrowRight,
|
||||
Cpu,
|
||||
Radio,
|
||||
Activity,
|
||||
KeyRound,
|
||||
ShieldAlert,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Truck,
|
||||
Monitor
|
||||
} from 'lucide-react';
|
||||
import { authApi } from '../api/auth';
|
||||
import './Login.css';
|
||||
|
||||
export const Login = () => {
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('Admin@123!');
|
||||
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 navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setShowError('');
|
||||
|
||||
try {
|
||||
// --- MOCK BYPASS FOR ADMIN DEMO ---
|
||||
if (username === 'admin' && password === 'Admin@123!') {
|
||||
localStorage.setItem('teleems_auth', 'true');
|
||||
localStorage.setItem('teleems_token', 'mock-admin-token-2026');
|
||||
localStorage.setItem('teleems_user', JSON.stringify({
|
||||
id: 'admin-mock-id',
|
||||
username: 'admin',
|
||||
name: 'Super Admin (Mock)',
|
||||
roles: ['CURESELECT_ADMIN'],
|
||||
metadata: { organization: { company_name: 'TeleEMS HQ' } }
|
||||
}));
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
// Direct login
|
||||
localStorage.setItem('teleems_auth', 'true');
|
||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
||||
localStorage.setItem('teleems_user', JSON.stringify(response.data.user || {}));
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
setShowError(response.message || 'Authentication failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setShowError('Unable to connect to authentication server');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setShowError('');
|
||||
|
||||
try {
|
||||
const response = await authApi.verifyMfa(mfaSessionToken, mfaCode);
|
||||
|
||||
if (response.status === 201 || response.status === 200) {
|
||||
localStorage.setItem('teleems_auth', 'true');
|
||||
localStorage.setItem('teleems_token', response.data.access_token || '');
|
||||
// Use user from response if available, otherwise use tempUser from first step
|
||||
const userToStore = response.data.user || tempUser || {};
|
||||
// Since MFA verification was successful, ensure mfa_enabled is true
|
||||
userToStore.mfa_enabled = true;
|
||||
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
||||
navigate('/');
|
||||
} else {
|
||||
setShowError(response.message || 'Invalid MFA code');
|
||||
}
|
||||
} catch (err) {
|
||||
setShowError('MFA verification failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-grid-decor" />
|
||||
<div className="scanline" />
|
||||
<div className="login-overlay" />
|
||||
|
||||
<motion.div
|
||||
key={loginStep}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="login-card glass glow-cyan"
|
||||
>
|
||||
<div className="login-header">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="login-logo"
|
||||
>
|
||||
<div className="logo-icon-wrapper">
|
||||
<Activity className="text-cyan-400" size={24} style={{ color: 'var(--accent-cyan)' }} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="login-title">
|
||||
{loginStep === 'login' ? 'TeleEMS Control' : 'MFA Verification'}
|
||||
</h1>
|
||||
<p className="login-subtitle">
|
||||
{loginStep === 'login' ? 'Sector Alpha • Command Node' : 'Enter Secure Access Code'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loginStep === 'login' ? (
|
||||
<form onSubmit={handleLogin} className="login-form">
|
||||
<div className="input-group">
|
||||
<label className="input-label">Operator Identification</label>
|
||||
<div className="input-wrapper">
|
||||
<User className="input-icon" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
className="login-input mono"
|
||||
placeholder="USERNAME"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label className="input-label">Access Encryption</label>
|
||||
<div className="input-wrapper" style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Lock className="input-icon" size={18} />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="login-input mono"
|
||||
placeholder="********"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ paddingRight: '40px' }}
|
||||
required
|
||||
/>
|
||||
<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 className="login-extras">
|
||||
<label className="remember-me">
|
||||
<input type="checkbox" style={{ accentColor: 'var(--accent-cyan)' }} />
|
||||
Keep Node Active
|
||||
</label>
|
||||
<a href="#" className="forgot-password">Auth Recovery?</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button glow-cyan"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Cpu size={20} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
AUTHENTICATE ACCESS
|
||||
<ArrowRight size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleMfaVerify} className="login-form">
|
||||
<div className="input-group">
|
||||
<label className="input-label">TOTP Security Token</label>
|
||||
<div className="input-wrapper">
|
||||
<KeyRound className="input-icon" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
className="login-input mono"
|
||||
placeholder="000 000"
|
||||
maxLength={6}
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', textAlign: 'center' }}>
|
||||
Enter the 6-digit code from your authenticator app.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button glow-cyan"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Cpu size={20} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
VERIFY IDENTITY
|
||||
<ShieldCheck size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoginStep('login')}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '8px'
|
||||
}}
|
||||
>
|
||||
← Back to Login
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="security-badge"
|
||||
style={{ color: 'var(--alert-red)' }}
|
||||
>
|
||||
<ShieldAlert size={14} />
|
||||
<span>{showError.toUpperCase()}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!showError && (
|
||||
<div className="security-badge">
|
||||
<ShieldCheck size={14} />
|
||||
<span>QUANTUM-ENCRYPTED CONNECTION ACTIVE</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="login-footer">
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
New Node Operator? <a href="#">Request Credentials</a>
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '12px', marginTop: '4px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<NavLink to="/fleet-login" style={{ color: 'var(--accent-cyan)', fontWeight: 700, textDecoration: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>
|
||||
<Truck size={16} /> ACCESS FLEET TERMINAL
|
||||
</NavLink>
|
||||
<NavLink to="/launcher" style={{ color: 'var(--text-secondary)', fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px', opacity: 0.8 }}>
|
||||
<Monitor size={14} /> VIEW ALL SYSTEM PORTALS
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Absolute card status dot — removed from card, placed at page level */}
|
||||
</motion.div>
|
||||
|
||||
{/* Page-level status indicators bottom-right */}
|
||||
<div className="login-status-indicators">
|
||||
<div className="status-pulse" style={{ backgroundColor: 'var(--accent-green, #00e272)', color: '#00e272' }} />
|
||||
<Radio size={12} />
|
||||
</div>
|
||||
|
||||
{/* Background decorative sys-log */}
|
||||
<div className="login-sys-log">
|
||||
<p>SYS_LOG: LISTENING ON PORT 8080</p>
|
||||
<p>ENCRYPTION: AES-256-GCM</p>
|
||||
<p>NODE_ID: EMS-K8S-042</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
315
src/pages/MasterData.tsx
Normal file
315
src/pages/MasterData.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Database,
|
||||
Stethoscope,
|
||||
AlertTriangle,
|
||||
Box,
|
||||
Plus,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Edit3,
|
||||
Trash2,
|
||||
FileText,
|
||||
Thermometer,
|
||||
ShieldAlert,
|
||||
Save,
|
||||
CheckCircle2,
|
||||
ShieldCheck,
|
||||
Hospital
|
||||
} from 'lucide-react';
|
||||
import { Card, StatCard } from '../components/Common';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
type MasterTab = 'SYMPTOMS' | 'INCIDENT_CATEGORIES' | 'MEDICAL_INVENTORY' | 'HOSPITAL_REFERRALS';
|
||||
|
||||
export const MasterDataManagement: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<MasterTab>('SYMPTOMS');
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '2.5rem', fontWeight: 900, background: 'linear-gradient(90deg, #3B82F6, #fff)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
Platform Master Data
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
Manage the categorical DNA of TeleEMS: Symptoms, Incident Logic, and Clinical Inventories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass" style={{ padding: '6px', borderRadius: '12px', display: 'flex', gap: '4px', background: 'rgba(255,255,255,0.05)' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('SYMPTOMS')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '8px', border: 'none',
|
||||
background: activeTab === 'SYMPTOMS' ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: activeTab === 'SYMPTOMS' ? '#000' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem'
|
||||
}}>
|
||||
<Stethoscope size={16} /> SYMPTOMS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('INCIDENT_CATEGORIES')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '8px', border: 'none',
|
||||
background: activeTab === 'INCIDENT_CATEGORIES' ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: activeTab === 'INCIDENT_CATEGORIES' ? '#000' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem'
|
||||
}}>
|
||||
<ShieldAlert size={16} /> INCIDENT CATEGORIES
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('MEDICAL_INVENTORY')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '8px', border: 'none',
|
||||
background: activeTab === 'MEDICAL_INVENTORY' ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: activeTab === 'MEDICAL_INVENTORY' ? '#000' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem'
|
||||
}}>
|
||||
<Box size={16} /> INVENTORY MASTER
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('HOSPITAL_REFERRALS')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: '8px', border: 'none',
|
||||
background: activeTab === 'HOSPITAL_REFERRALS' ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: activeTab === 'HOSPITAL_REFERRALS' ? '#000' : 'var(--text-secondary)',
|
||||
fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem'
|
||||
}}>
|
||||
<Hospital size={16} /> HOSPITALS
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'SYMPTOMS' && <SymptomMaster key="symptoms" />}
|
||||
{activeTab === 'INCIDENT_CATEGORIES' && <IncidentCategoryMaster key="categories" />}
|
||||
{activeTab === 'MEDICAL_INVENTORY' && <InventoryMaster key="inventory" />}
|
||||
{activeTab === 'HOSPITAL_REFERRALS' && <HospitalReferralMaster key="referrals" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- SUB-MODULES ---
|
||||
|
||||
const SymptomMaster = () => {
|
||||
const symptoms = [
|
||||
{ id: 'S-001', name: 'Cardiac Arrest', category: 'Immediate (Red)', instructions: 'Begin CPR, Attach Defibrillator', lang: 'EN, TN, HI' },
|
||||
{ id: 'S-002', name: 'Compound Fracture', category: 'Urgent (Orange)', instructions: 'Stabilize limb, Control bleeding', lang: 'EN, TN' },
|
||||
{ id: 'S-003', name: 'Minor Laceration', category: 'Minor (Green)', instructions: 'Clean wound, Apply dressing', lang: 'EN' },
|
||||
{ id: 'S-004', name: 'Respiratory Distress', category: 'Immediate (Red)', instructions: 'Administer Oxygen, Sitting position', lang: 'EN, TN, HI' },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div className="glass" style={{ padding: '8px 16px', borderRadius: '8px', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Search size={16} color="var(--text-secondary)" />
|
||||
<input type="text" placeholder="Search symptoms..." style={{ background: 'transparent', border: 'none', color: '#fff', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
<select className="glass" style={{ padding: '8px 16px', borderRadius: '8px', background: 'rgba(0,0,0,0.5)', color: '#fff', border: '1px solid var(--card-border)', fontSize: '0.85rem' }}>
|
||||
<option>All Severities</option>
|
||||
<option>Red (Immediate)</option>
|
||||
<option>Orange (Urgent)</option>
|
||||
<option>Green (Minor)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="glass" style={{ padding: '10px 20px', background: 'var(--accent-cyan)', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<Plus size={18} /> ADD NEW SYMPTOM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(255,255,255,0.03)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '16px' }}>Symptom ID</th>
|
||||
<th style={{ padding: '16px' }}>Clinical Name</th>
|
||||
<th style={{ padding: '16px' }}>Severity Cluster</th>
|
||||
<th style={{ padding: '16px' }}>First Aid Protocol</th>
|
||||
<th style={{ padding: '16px' }}>Localization</th>
|
||||
<th style={{ padding: '16px', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{symptoms.map(s => (
|
||||
<tr key={s.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||||
<td style={{ padding: '16px' }} className="mono">{s.id}</td>
|
||||
<td style={{ padding: '16px', fontWeight: 800 }}>{s.name}</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
fontSize: '0.65rem', padding: '4px 8px', borderRadius: '4px', fontWeight: 800,
|
||||
background: s.category.includes('Red') ? 'rgba(255, 59, 59, 0.1)' : 'rgba(255, 184, 0, 0.1)',
|
||||
color: s.category.includes('Red') ? 'var(--alert-red)' : 'var(--warning-amber)',
|
||||
border: `1px solid ${s.category.includes('Red') ? 'rgba(255, 59, 59, 0.3)' : 'rgba(255, 184, 0, 0.3)'}`
|
||||
}}>{s.category.toUpperCase()}</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px', color: 'var(--text-secondary)' }}>{s.instructions}</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
{s.lang.split(', ').map(l => (
|
||||
<span key={l} style={{ fontSize: '0.6rem', padding: '2px 4px', background: 'rgba(255,255,255,0.05)', borderRadius: '2px' }}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button style={{ background: 'transparent', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer' }}><Edit3 size={16} /></button>
|
||||
<button style={{ background: 'transparent', border: 'none', color: 'var(--alert-red)', cursor: 'pointer' }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const IncidentCategoryMaster = () => {
|
||||
const categories = [
|
||||
{ name: 'Red - Immediate', desc: 'Life Threatening Emergency', color: 'var(--alert-red)', escalation: 'Immediate Pilot/EMT + Supervisor notification' },
|
||||
{ name: 'Orange - Urgent', desc: 'Non-life threatening but critical', color: 'var(--warning-amber)', escalation: 'Pilot/EMT notification within 2 mins' },
|
||||
{ name: 'Green - Minor', desc: 'Walking wounded / Low priority', color: 'var(--accent-green)', escalation: 'Standard dispatch queue' },
|
||||
{ name: 'Blue - IFT', desc: 'Inter-Facility Transfer', color: 'var(--accent-cyan)', escalation: 'Scheduled transport routing' },
|
||||
{ name: 'Black - Deceased', desc: 'Dead on Arrival / Scene', color: '#4A5568', escalation: 'Mortuary / Police notification' },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '24px' }}>
|
||||
{categories.map((c, i) => (
|
||||
<Card key={i} title={c.name} subtitle={c.desc} style={{ borderLeft: `4px solid ${c.color}` }}>
|
||||
<div style={{ marginTop: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', textTransform: 'uppercase', marginBottom: '4px' }}>Escalation Logic</div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>{c.escalation}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '10px' }}>
|
||||
<button style={{ background: 'transparent', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', padding: '6px 12px', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 700, cursor: 'pointer' }}>CONFIGURE RULES</button>
|
||||
<button style={{ background: 'transparent', border: '1px solid var(--card-border)', color: 'var(--accent-cyan)', padding: '6px 12px', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 700, cursor: 'pointer' }}>EDIT</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div style={{ border: '2px dashed var(--card-border)', borderRadius: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: '40px' }} className="hover-glow">
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Plus size={32} color="var(--text-secondary)" style={{ margin: '0 auto 10px' }} />
|
||||
<div style={{ fontWeight: 800, color: 'var(--text-secondary)' }}>ADD CUSTOM CATEGORY</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const InventoryMaster = () => {
|
||||
const items = [
|
||||
{ name: 'Epinephrine (1mg)', category: 'Drug', unit: 'Ampule', minStock: 10, expiry: true },
|
||||
{ name: 'Sterile Gauze (4x4)', category: 'Disposable', unit: 'Pack', minStock: 50, expiry: false },
|
||||
{ name: 'Automatic Defibrillator', category: 'Medical Device', unit: 'Unit', minStock: 1, expiry: false },
|
||||
{ name: 'Oxygen Cylinder (Portable)', category: 'Reusable', unit: 'Cylinder', minStock: 2, expiry: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', fontWeight: 800 }}>Medical Inventory Master List</h3>
|
||||
<button className="glass" style={{ padding: '10px 20px', background: 'var(--accent-cyan)', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<Save size={18} /> SAVE MASTER LIST
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '20px' }}>
|
||||
<StatCard label="Total SKU Nodes" value="482" icon={Box} glowColor="cyan" />
|
||||
<StatCard label="Critical Low Alerts" value="12" icon={AlertTriangle} glowColor="red" />
|
||||
<StatCard label="Global Stock Value" value="₹12.4L" icon={Database} glowColor="green" />
|
||||
<StatCard label="Inventory Compliance" value="100%" icon={ShieldCheck} glowColor="cyan" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(255,255,255,0.03)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '16px' }}>Item Name</th>
|
||||
<th style={{ padding: '16px' }}>Category</th>
|
||||
<th style={{ padding: '16px' }}>Unit</th>
|
||||
<th style={{ padding: '16px' }}>Min Alert Threshold</th>
|
||||
<th style={{ padding: '16px' }}>Expiry Tracking</th>
|
||||
<th style={{ padding: '16px', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||||
<td style={{ padding: '16px', fontWeight: 800 }}>{item.name}</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{ fontSize: '0.65rem', padding: '4px 8px', background: 'rgba(255,255,255,0.05)', borderRadius: '4px', border: '1px solid var(--card-border)' }}>{item.category.toUpperCase()}</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>{item.unit}</td>
|
||||
<td style={{ padding: '16px' }} className="mono">{item.minStock}</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
{item.expiry ? <CheckCircle2 size={16} color="var(--accent-green)" /> : <Trash2 size={16} color="rgba(255,255,255,0.1)" />}
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button style={{ background: 'transparent', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', padding: '4px 8px', borderRadius: '4px', fontSize: '0.7rem', cursor: 'pointer' }}>BATCH INFO</button>
|
||||
<button style={{ background: 'transparent', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer' }}><Edit3 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
const HospitalReferralMaster = () => {
|
||||
const networks = [
|
||||
{ name: 'City Govt Hospital Network', type: 'Government', nodes: 12, region: 'Chennai Central' },
|
||||
{ name: 'Apollo Group Synergy', type: 'Private', nodes: 5, region: 'Regional North' },
|
||||
{ name: 'District Trauma Collective', type: 'Trust', nodes: 8, region: 'Salem/Erode' },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '24px' }}>
|
||||
{networks.map((net, i) => (
|
||||
<Card key={i} title={net.name} subtitle={`${net.type} • ${net.region}`}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Hospital size={20} color="var(--accent-cyan)" />
|
||||
<span style={{ fontWeight: 800, fontSize: '1.2rem' }}>{net.nodes} Nodes</span>
|
||||
</div>
|
||||
<button style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)', color: 'var(--accent-cyan)', padding: '6px 12px', borderRadius: '6px', fontSize: '0.7rem', fontWeight: 800, cursor: 'pointer' }}>MANAGE NETWORK</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card title="Global Referral Mapping">
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginBottom: '24px' }}>Configure specialty routing rules for Cardiac, Trauma, and Burn centers across all aggregator zones.</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{[
|
||||
{ target: 'Cardiac Emergencies', hospital: 'Government General Hospital (Cath Lab)', protocol: 'Immediate Redirect' },
|
||||
{ target: 'Penetrating Trauma', hospital: 'District Trauma Center (Level 1)', protocol: 'ED Pre-alert Pulse' },
|
||||
{ target: 'Maternal Emergencies', hospital: 'Regional Women & Child Hub', protocol: 'Specialist Standby' },
|
||||
].map((rule, i) => (
|
||||
<div key={i} style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderRadius: '12px', border: '1px solid var(--card-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: 'var(--accent-cyan)' }}>{rule.target}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Destination: {rule.hospital}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.7rem', fontWeight: 800, textTransform: 'uppercase' }}>{rule.protocol}</div>
|
||||
<Edit3 size={14} style={{ marginTop: '4px', cursor: 'pointer', opacity: 0.5 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
373
src/pages/PatientClinical.tsx
Normal file
373
src/pages/PatientClinical.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Stethoscope,
|
||||
Package,
|
||||
Building2,
|
||||
Plus,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Globe2,
|
||||
Tag,
|
||||
BriefcaseMedical,
|
||||
MapPin,
|
||||
Crosshair
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../components/Common';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
type MasterTab = 'SYMPTOMS' | 'INCIDENTS' | 'INVENTORY' | 'HOSPITALS';
|
||||
|
||||
export const PatientClinical: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<MasterTab>('SYMPTOMS');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '2.5rem', fontWeight: 900, background: 'linear-gradient(90deg, var(--accent-blue), var(--text-primary))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
Platform Master Data
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
System-wide taxonomies, medical protocols, and entity masters.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<div className="glass" style={{ padding: '4px 8px', borderRadius: '12px', display: 'flex', background: 'rgba(0,0,0,0.03)' }}>
|
||||
{(['SYMPTOMS', 'INCIDENTS', 'INVENTORY', 'HOSPITALS'] as MasterTab[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: activeTab === tab ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: activeTab === tab ? '#fff' : 'var(--text-secondary)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(250px, 300px) 1fr', gap: '32px' }}>
|
||||
{/* SIDEBAR FOR FILTERING / SEARCH */}
|
||||
<aside style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<Card title="Query & Filter">
|
||||
<div style={{ position: 'relative', marginBottom: '16px' }}>
|
||||
<Search size={16} style={{ position: 'absolute', left: '12px', top: '12px', color: 'var(--text-secondary)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${activeTab.toLowerCase()}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ width: '100%', padding: '10px 10px 10px 36px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', fontWeight: 700, textTransform: 'uppercase', marginBottom: '4px' }}>Status Filter</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||
<input type="checkbox" defaultChecked style={{ accentColor: 'var(--accent-cyan)' }} /> Active Entities
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||
<input type="checkbox" style={{ accentColor: 'var(--accent-cyan)' }} /> Deactivated
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Quick Stats">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Total {activeTab}</span>
|
||||
<span className="mono" style={{ fontWeight: 700, color: 'var(--accent-cyan)' }}>
|
||||
{activeTab === 'SYMPTOMS' ? '242' : activeTab === 'INCIDENTS' ? '12' : activeTab === 'INVENTORY' ? '1,204' : '45'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: '4px', background: 'rgba(0,0,0,0.02)', borderRadius: '2px' }}>
|
||||
<div style={{ width: '65%', height: '100%', background: 'linear-gradient(90deg, var(--accent-cyan), var(--accent-green))', borderRadius: '2px' }}></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>
|
||||
Last sync: Today at 10:45 AM
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
{/* MAIN CONTENT AREA */}
|
||||
<main>
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'SYMPTOMS' && <SymptomMaster key="symptoms" />}
|
||||
{activeTab === 'INCIDENTS' && <IncidentCategoryMaster key="incidents" />}
|
||||
{activeTab === 'INVENTORY' && <MedicalInventoryMaster key="inventory" />}
|
||||
{activeTab === 'HOSPITALS' && <HospitalReferralMaster key="hospitals" />}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 5.5.1 Symptom Master
|
||||
const SymptomMaster = () => {
|
||||
const symptoms = [
|
||||
{ name: 'Chest Pain / Cardiac Arrest', severity: 'Immediate', color: '#ff3b3b', languages: ['EN', 'HI', 'TA', 'ML'], instructions: 'Keep patient calm, check pulse, start CPR if unconscious.' },
|
||||
{ name: 'Breathing Difficulty / Asthma', severity: 'Immediate', color: '#ff3b3b', languages: ['EN', 'HI', 'TA'], instructions: 'Ensure open airway, assist with inhaler if available.' },
|
||||
{ name: 'Uncontrolled Bleeding', severity: 'Immediate', color: '#ff3b3b', languages: ['EN', 'HI'], instructions: 'Apply direct pressure to wound using clean cloth.' },
|
||||
{ name: 'High Fever / Convulsions', severity: 'Urgent', color: '#ffb800', languages: ['EN', 'HI', 'TA', 'KN'], instructions: 'Cool down patient, do not restrain during seizure.' },
|
||||
{ name: 'Minor Fracture / Sprain', severity: 'Minor', color: '#00ff88', languages: ['EN', 'TA'], instructions: 'Immobilize affected limb, apply cold compress.' },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700 }}>
|
||||
<Stethoscope size={24} color="var(--accent-cyan)" /> Symptom & Protocol Master
|
||||
</h3>
|
||||
<button className="glass" style={{ padding: '8px 16px', background: 'var(--accent-cyan)', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<Plus size={16} /> ADD SYMPTOM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{symptoms.map((s, i) => (
|
||||
<Card key={i} className="hover-glow" style={{ padding: '0', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', minHeight: '100px' }}>
|
||||
<div style={{ width: '8px', background: s.color }}></div>
|
||||
<div style={{ flex: 1, padding: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '1.1rem' }}>{s.name}</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '6px' }}>
|
||||
{s.languages.map(l => (
|
||||
<span key={l} style={{ fontSize: '0.6rem', padding: '2px 6px', background: 'rgba(0,0,0,0.02)', borderRadius: '4px', color: 'var(--text-secondary)', border: '1px solid var(--card-border)' }}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '6px' }}>
|
||||
<div style={{ fontSize: '0.7rem', fontWeight: 800, color: s.color, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{s.severity}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '4px', justifyContent: 'flex-end' }}>
|
||||
<Clock size={12} /> Auto-escalation: 5m
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '300px', background: 'rgba(0,0,0,0.02)', padding: '16px', fontSize: '0.75rem', borderLeft: '1px solid var(--card-border)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ color: 'var(--accent-cyan)', fontWeight: 700, marginBottom: '4px', fontSize: '0.65rem', textTransform: 'uppercase' }}>First-Aid Instructions</div>
|
||||
<p style={{ margin: 0, color: 'var(--text-secondary)', lineHeight: '1.4' }}>{s.instructions}</p>
|
||||
</div>
|
||||
<div style={{ width: '60px', display: 'flex', alignItems: 'center', justifyContent: 'center', borderLeft: '1px solid var(--card-border)', cursor: 'pointer', background: 'rgba(0,0,0,0.01)' }}>
|
||||
<ChevronRight size={20} color="var(--text-secondary)" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// 5.5.2 Incident Category Master
|
||||
const IncidentCategoryMaster = () => {
|
||||
const categories = [
|
||||
{ name: 'Red — Immediate', code: 'IMMEDIATE', level: 'Life Threatening', color: '#ff3b3b', description: 'Immediate threat to life or limb.' },
|
||||
{ name: 'Orange — Urgent', code: 'URGENT', level: 'Critical but Stable', color: '#ffb800', description: 'Needs prompt medical attention.' },
|
||||
{ name: 'Green — Minor', code: 'MINOR', level: 'Walking Wounded', color: '#00ff88', description: 'Non-life threatening injuries.' },
|
||||
{ name: 'White — Non-Emergency', code: 'NON_EMG', level: 'Transport / Checkup', color: '#ffffff', description: 'Planned medical transport.' },
|
||||
{ name: 'Black — Dead', code: 'DEAD', level: 'Deceased', color: '#555555', description: 'Confirmed DOA or expired on site.' },
|
||||
{ name: 'Blue — IFT', code: 'IFT', level: 'Inter Facility Transfer', color: '#3B82F6', description: 'Hospital to hospital transfer.' },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700 }}>
|
||||
<Activity size={24} color="var(--accent-cyan)" /> Incident Category Master
|
||||
</h3>
|
||||
<button className="glass" style={{ padding: '8px 16px', background: 'var(--accent-cyan)', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<Plus size={16} /> NEW CATEGORY
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '20px' }}>
|
||||
{categories.map((c, i) => (
|
||||
<Card key={i} style={{ borderLeft: `6px solid ${c.color}`, transition: 'all 0.3s ease' }} className="hover-glow">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: '1.1rem', color: c.color }}>{c.name}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{c.level}</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: '0.7rem', padding: '2px 8px', background: 'rgba(0,0,0,0.02)', borderRadius: '4px', border: '1px solid var(--card-border)' }}>{c.code}</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '16px', lineHeight: '1.6' }}>
|
||||
{c.description}
|
||||
</p>
|
||||
<div style={{ marginTop: '20px', paddingTop: '16px', borderTop: '1px solid var(--card-border)', display: 'flex', gap: '20px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Auto-Dispatch</div>
|
||||
<div style={{ color: 'var(--accent-green)', fontWeight: 700, fontSize: '0.75rem' }}>ENABLED</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Escalation</div>
|
||||
<div style={{ color: 'var(--warning-amber)', fontWeight: 700, fontSize: '0.75rem' }}>T+2 MINS</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Priority</div>
|
||||
<div style={{ color: 'var(--accent-cyan)', fontWeight: 700, fontSize: '0.75rem' }}>LEVEL {categories.length - i}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// 5.5.3 Medical Inventory Master
|
||||
const MedicalInventoryMaster = () => {
|
||||
const inventory = [
|
||||
{ name: 'Adrenaline (Epinephrine) Injection', category: 'Drug', stock: 450, min: 100, unit: 'Ampoules', hsn: '30049099' },
|
||||
{ name: 'Defibrillator Pads (Pediatric)', category: 'Disposable', stock: 24, min: 50, alert: true, unit: 'Pairs', hsn: '90189099' },
|
||||
{ name: 'Oxygen Cylinder (Type D)', category: 'Medical Device', stock: 12, min: 5, unit: 'Cylinders', hsn: '73110010' },
|
||||
{ name: 'Sterile Gauze 4x4', category: 'Disposable', stock: 2400, min: 1000, unit: 'Packets', hsn: '30059040' },
|
||||
{ name: 'Portable Ventilator V1', category: 'Medical Device', stock: 8, min: 2, unit: 'Units', hsn: '90192000' },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700 }}>
|
||||
<Package size={24} color="var(--accent-cyan)" /> Medical Inventory Master
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button className="glass" style={{ padding: '8px 16px', background: 'rgba(0,0,0,0.02)', color: 'var(--text-primary)', border: '1px solid var(--card-border)', borderRadius: '6px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontSize: '0.8rem' }}>
|
||||
<BriefcaseMedical size={16} /> BATCH TRACKER
|
||||
</button>
|
||||
<button className="glass" style={{ padding: '8px 16px', background: 'var(--accent-cyan)', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontSize: '0.8rem' }}>
|
||||
<Plus size={16} /> NEW ITEM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(0,0,0,0.02)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '16px' }}>Item Details</th>
|
||||
<th style={{ padding: '16px' }}>Category</th>
|
||||
<th style={{ padding: '16px' }}>Stock Level</th>
|
||||
<th style={{ padding: '16px' }}>HSN Code</th>
|
||||
<th style={{ padding: '16px', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inventory.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.02)' }}>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontWeight: 700 }}>{item.name}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>ID: #INV-{1000 + i}</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{ padding: '4px 8px', background: 'rgba(59, 130, 246, 0.1)', color: 'var(--accent-cyan)', fontSize: '0.7rem', borderRadius: '4px', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ flex: 1, minWidth: '80px', maxWidth: '120px', height: '6px', background: 'rgba(0,0,0,0.03)', borderRadius: '3px' }}>
|
||||
<div style={{ width: `${Math.min(100, (item.stock / (item.min * 2)) * 100)}%`, height: '100%', background: item.alert ? 'var(--alert-red)' : 'var(--accent-green)', borderRadius: '3px', boxShadow: item.alert ? '0 0 10px rgba(255, 59, 59, 0.2)' : 'none' }}></div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontWeight: 700, color: item.alert ? 'var(--alert-red)' : 'inherit' }}>
|
||||
{item.stock} <span style={{ fontSize: '0.7rem', fontWeight: 400, color: 'var(--text-secondary)' }}>/ {item.unit}</span>
|
||||
</div>
|
||||
{item.alert && <AlertCircle size={14} color="var(--alert-red)" />}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }} className="mono">{item.hsn}</td>
|
||||
<td style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<button style={{ background: 'transparent', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.75rem', fontWeight: 600 }}>EDIT</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// 5.5.4 Hospital & Referral Master
|
||||
const HospitalReferralMaster = () => {
|
||||
const hospitals = [
|
||||
{ name: 'RGGGH (General Hospital)', type: 'Government', district: 'Chennai', coordinates: '13.0827, 80.2707', specialties: ['Trauma', 'Burns', 'General Medicine'], bedCap: '500+', accreditation: 'NABH' },
|
||||
{ name: 'Apollo Main Hospital', type: 'Private', district: 'Chennai', coordinates: '13.0655, 80.2505', specialties: ['Cardiac', 'Neurology', 'Orthopedics'], bedCap: '350', accreditation: 'JCI/NABH' },
|
||||
{ name: 'Stanley Medical College', type: 'Government', district: 'Chennai', coordinates: '13.1026, 80.2845', specialties: ['Pediatrics', 'Obstetrics'], bedCap: '420', accreditation: 'NABH' },
|
||||
{ name: 'Madura Medical Centre', type: 'Private', district: 'Madurai', coordinates: '9.9252, 78.1198', specialties: ['Gastroenterology'], bedCap: '120', accreditation: 'State Board' },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700 }}>
|
||||
<Building2 size={24} color="var(--accent-cyan)" /> Hospital & Referral Network
|
||||
</h3>
|
||||
<button className="glass" style={{ padding: '8px 16px', background: 'var(--accent-cyan)', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<Plus size={16} /> REGISTER FACILITY
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '20px' }}>
|
||||
{hospitals.map((h, i) => (
|
||||
<Card key={i} className="hover-glow" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: '1.1rem' }}>{h.name}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--accent-cyan)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 700 }}>{h.type} Facility</span>
|
||||
<span style={{ fontSize: '0.6rem', padding: '1px 6px', background: 'rgba(0,0,0,0.02)', borderRadius: '4px', color: 'var(--text-secondary)' }}>{h.accreditation}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.75rem', color: 'var(--text-secondary)', fontWeight: 600 }}>
|
||||
<MapPin size={14} color="var(--accent-cyan)" /> {h.district}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '20px' }}>
|
||||
{h.specialties.map(s => (
|
||||
<span key={s} style={{ fontSize: '0.65rem', padding: '3px 10px', background: 'rgba(0, 255, 136, 0.05)', border: '1px solid rgba(0, 255, 136, 0.2)', color: 'var(--accent-green)', borderRadius: '100px', fontWeight: 600 }}>{s}</span>
|
||||
))}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: 'auto' }}>
|
||||
<div style={{ background: 'rgba(0,0,0,0.02)', padding: '12px', borderRadius: '8px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase', marginBottom: '4px' }}>GPS Location</div>
|
||||
<div className="mono" style={{ fontSize: '0.75rem' }}>{h.coordinates}</div>
|
||||
</div>
|
||||
<div style={{ background: 'rgba(0,0,0,0.02)', padding: '12px', borderRadius: '8px', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase', marginBottom: '4px' }}>Bed Capacity</div>
|
||||
<div className="mono" style={{ fontSize: '0.75rem', color: 'var(--accent-cyan)', fontWeight: 700 }}>{h.bedCap} UNITS</div>
|
||||
</div>
|
||||
</div> </div>
|
||||
|
||||
<button style={{ width: '100%', marginTop: '16px', background: 'transparent', border: '1px dashed var(--card-border)', color: 'var(--text-secondary)', padding: '8px', borderRadius: '6px', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer', transition: 'all 0.2s ease' }}>
|
||||
CONFIGURE ROUTING RULES
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
419
src/pages/PerspectiveLauncher.css
Normal file
419
src/pages/PerspectiveLauncher.css
Normal file
@@ -0,0 +1,419 @@
|
||||
.launcher-page {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #020617;
|
||||
color: #f8fafc;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.launcher-page::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.launcher-page::-webkit-scrollbar-track {
|
||||
background: rgba(2, 6, 23, 0.8);
|
||||
}
|
||||
|
||||
.launcher-page::-webkit-scrollbar-thumb {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.launcher-page::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Background Effects */
|
||||
.launcher-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.launcher-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
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 */
|
||||
.launcher-header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 24px 40px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(2, 6, 23, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.launcher-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.launcher-logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
font-size: 0.75rem;
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.launcher-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.security-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.config-btn {
|
||||
background: #f8fafc;
|
||||
color: #020617;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.config-btn:hover {
|
||||
background: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.launcher-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.launcher-intro {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.launcher-main-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 900;
|
||||
margin: 0 0 16px 0;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.launcher-main-title span {
|
||||
background: linear-gradient(to right, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.launcher-main-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.portal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.portal-card {
|
||||
position: relative;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.portal-card:hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.portal-card-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(circle at top right, var(--accent-color), transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.portal-card:hover .portal-card-glow {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.portal-card-inner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-icon-container {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 24px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.portal-card:hover .portal-icon-container {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 20px var(--accent-color);
|
||||
}
|
||||
|
||||
.portal-subtitle {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.portal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.portal-description {
|
||||
font-size: 0.9375rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 32px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.portal-footer {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.portal-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.portal-action svg {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.portal-card:hover .portal-action svg {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.launcher-footer {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 32px 40px;
|
||||
background: rgba(2, 6, 23, 0.8);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.launcher-main-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.portal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.launcher-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.launcher-content {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.footer-info {
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
221
src/pages/PerspectiveLauncher.tsx
Normal file
221
src/pages/PerspectiveLauncher.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Building2,
|
||||
Building,
|
||||
Stethoscope,
|
||||
Activity,
|
||||
User,
|
||||
Scan,
|
||||
ShoppingCart,
|
||||
ArrowRight,
|
||||
Shield,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
Truck
|
||||
} from 'lucide-react';
|
||||
import './PerspectiveLauncher.css';
|
||||
|
||||
interface PortalCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
path: string;
|
||||
description: string;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
const PortalCard: React.FC<PortalCardProps> = ({ title, subtitle, icon: Icon, color, path, description, delay }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
whileHover={{ scale: 1.02, y: -5 }}
|
||||
onClick={() => navigate(path)}
|
||||
className="portal-card"
|
||||
style={{ '--accent-color': color } as React.CSSProperties}
|
||||
>
|
||||
<div className="portal-card-glow" />
|
||||
<div className="portal-card-inner">
|
||||
<div className="portal-icon-container">
|
||||
<Icon size={32} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="portal-content">
|
||||
<span className="portal-subtitle">{subtitle}</span>
|
||||
<h2 className="portal-title">{title}</h2>
|
||||
<p className="portal-description">{description}</p>
|
||||
</div>
|
||||
<div className="portal-footer">
|
||||
<span className="portal-action">Enter Portal <ArrowRight size={16} /></span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PerspectiveLauncher: React.FC = () => {
|
||||
const portals = [
|
||||
{
|
||||
title: 'Admin Control',
|
||||
subtitle: 'SYSTEM ADMINISTRATION',
|
||||
icon: Shield,
|
||||
color: '#f8fafc',
|
||||
path: '/login/admin',
|
||||
description: 'Global system configuration, user management, and infrastructure monitoring.'
|
||||
},
|
||||
{
|
||||
title: 'Hospital Group',
|
||||
subtitle: 'REGIONAL MANAGEMENT',
|
||||
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',
|
||||
path: '/login/hospital',
|
||||
description: 'End-to-end management of emergency department operations and bed tracking.'
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
subtitle: 'CLINICAL CARE',
|
||||
icon: Stethoscope,
|
||||
color: '#8b5cf6',
|
||||
path: '/login/provider',
|
||||
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.'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="launcher-page">
|
||||
<div className="launcher-bg">
|
||||
<div className="launcher-grid" />
|
||||
<div className="launcher-blob blob-1" />
|
||||
<div className="launcher-blob blob-2" />
|
||||
<div className="launcher-blob blob-3" />
|
||||
</div>
|
||||
|
||||
<header className="launcher-header">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="launcher-brand"
|
||||
>
|
||||
<div className="launcher-logo">
|
||||
<Activity className="text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="brand-name">TeleEMS <span className="brand-version">v2.4</span></h1>
|
||||
<p className="brand-tagline">Integrated Health Ecosystem</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="launcher-actions">
|
||||
<div className="security-status">
|
||||
<Shield size={16} />
|
||||
<span>Secure Enterprise Node</span>
|
||||
</div>
|
||||
<button className="config-btn">
|
||||
<Plus size={18} /> Add Module
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="launcher-content">
|
||||
<div className="launcher-intro">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="launcher-main-title"
|
||||
>
|
||||
Select Your <span>Perspective</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="launcher-main-subtitle"
|
||||
>
|
||||
Access the specific toolset tailored to your role within the healthcare network.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className="portal-grid">
|
||||
{portals.map((portal, index) => (
|
||||
<PortalCard key={portal.title} {...portal} delay={0.2 + index * 0.05} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="launcher-footer">
|
||||
<div className="footer-info">
|
||||
<div className="info-item">
|
||||
<span className="label">System Load</span>
|
||||
<div className="progress-bar"><div className="progress-fill" style={{ width: '24%' }} /></div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Active Nodes</span>
|
||||
<span className="value">1,284</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">Live Fleet</span>
|
||||
<span className="value">48 Units</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<a href="#">Support</a>
|
||||
<a href="#">Documentation <ExternalLink size={12} /></a>
|
||||
<a href="#">System Status</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
179
src/pages/PlatformConfig.tsx
Normal file
179
src/pages/PlatformConfig.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
Save,
|
||||
Mail,
|
||||
Smartphone,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../components/Common';
|
||||
import { MfaSettings } from '../components/MfaSettings';
|
||||
|
||||
export const PlatformConfig: React.FC = () => {
|
||||
return (
|
||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '2.5rem', fontWeight: 900, background: 'linear-gradient(90deg, #3B82F6, #fff)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
System Configuration
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
Configure global thresholds, gateway integrations, and platform-wide feature flags.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button className="glass" style={{ padding: '12px 24px', background: 'var(--accent-cyan)', color: '#000', border: 'none', borderRadius: '10px', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', boxShadow: '0 0 20px rgba(0,212,255,0.4)' }}>
|
||||
<Save size={18} /> PERSIST ALL CHANGES
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '24px' }}>
|
||||
{/* 5.7.1 Global SLA Thresholds */}
|
||||
<Card title="Global SLA Thresholds" subtitle="Response target targets by category">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{[
|
||||
{ category: 'IMMEDIATE (RED)', target: '8 Minutes', threshold: '95%' },
|
||||
{ category: 'URGENT (ORANGE)', target: '15 Minutes', threshold: '90%' },
|
||||
{ category: 'MINOR (GREEN)', target: '30 Minutes', threshold: '85%' },
|
||||
{ category: 'IFT (BLUE)', target: 'Scheduled', threshold: '98%' },
|
||||
].map((sla, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 800, color: sla.category.includes('RED') ? 'var(--alert-red)' : 'inherit' }}>{sla.category}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>Target: {sla.target}</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: '1rem', fontWeight: 800, color: 'var(--accent-cyan)' }}>{sla.threshold}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 5.7.2 Notification Gateways */}
|
||||
<Card title="Messaging Gateways" subtitle="FCM, SMS, and Email service status.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ padding: '16px', background: 'rgba(0,0,0,0.2)', border: '1px solid var(--card-border)', borderRadius: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Smartphone size={18} color="var(--accent-cyan)" />
|
||||
<span style={{ fontWeight: 800, fontSize: '0.85rem' }}>SMS Gateway</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.65rem', padding: '2px 8px', background: 'rgba(0,255,136,0.1)', color: 'var(--accent-green)', borderRadius: '40px', fontWeight: 800 }}>PRIMARY</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Provider: Twilio (AWS Region)</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px', background: 'rgba(0,0,0,0.2)', border: '1px solid var(--card-border)', borderRadius: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Mail size={18} color="var(--accent-cyan)" />
|
||||
<span style={{ fontWeight: 800, fontSize: '0.85rem' }}>Email SMTP</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.65rem', padding: '2px 8px', background: 'rgba(0,255,136,0.1)', color: 'var(--accent-green)', borderRadius: '40px', fontWeight: 800 }}>HEALTHY</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Relay: SendGrid (SES Failover)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* MFA Settings */}
|
||||
<MfaSettings />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '24px' }}>
|
||||
<Card title="Feature Flags" subtitle="Enable/Disable modules per operator.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{[
|
||||
{ name: 'Air Ambulance Dispatch', enabled: true },
|
||||
{ name: 'IoT Telemetry Feed', enabled: true },
|
||||
{ name: 'RTVS Video Link', enabled: true },
|
||||
{ name: 'Inter-Facility Transfer', enabled: false },
|
||||
{ name: 'Pharmacy Cross-Sell', enabled: false },
|
||||
{ name: 'Predictive Load Balancer', enabled: true },
|
||||
].map((flag, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 0' }}>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>{flag.name}</span>
|
||||
<div style={{
|
||||
width: '40px', height: '22px', background: flag.enabled ? 'var(--accent-green)' : 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '11px', position: 'relative', cursor: 'pointer', border: flag.enabled ? 'none' : '1px solid var(--card-border)'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '18px', height: '18px', background: '#fff', borderRadius: '50%',
|
||||
position: 'absolute', top: '2px', left: flag.enabled ? '20px' : '2px',
|
||||
transition: 'all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
|
||||
}}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="IoT Device Profiles" subtitle="Firmware versions and pairing pool management.">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(255,255,255,0.03)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '16px' }}>Device Model</th>
|
||||
<th style={{ padding: '16px' }}>Firmware Ver</th>
|
||||
<th style={{ padding: '16px' }}>Active Units</th>
|
||||
<th style={{ padding: '16px' }}>Auto-Update</th>
|
||||
<th style={{ padding: '16px', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ model: 'TM-Hub G4 (Gateway)', fw: 'v4.2.1-stable', units: 480, auto: 'ENABLED' },
|
||||
{ model: 'TM-Pat-Monitor X1', fw: 'v1.0.8-patch5', units: 120, auto: 'DISABLED' },
|
||||
{ model: 'TM-Air-Comm SatLink', fw: 'v2.2.0', units: 15, auto: 'ENABLED' },
|
||||
{ model: 'TM-Drug-Dispense-B1', fw: 'v0.9.5-beta', units: 85, auto: 'MANUAL' },
|
||||
].map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||||
<td style={{ padding: '16px', fontWeight: 700 }}>{row.model}</td>
|
||||
<td style={{ padding: '16px' }} className="mono">{row.fw}</td>
|
||||
<td style={{ padding: '16px' }}>{row.units}</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: row.auto === 'ENABLED' ? 'var(--accent-green)' : 'var(--text-secondary)' }}>{row.auto}</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<button style={{ background: 'transparent', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', padding: '4px 10px', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 600, cursor: 'pointer' }}>PUSH FW</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '24px' }}>
|
||||
<Card title="System Audit Stream" subtitle="Live platform event sequence (Read Only)">
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
}} className="no-scrollbar">
|
||||
{[
|
||||
{ t: '14:22:01', msg: 'SEC: MFA_POL_UPDATED: UserID #admin enforced high-entropy backup codes.', level: 'INFO' },
|
||||
{ t: '14:21:45', msg: 'NET: GW_SMS_FAILOVER: Switched priority to AWS SES for Asia-South-1.', level: 'WARN' },
|
||||
{ t: '14:20:12', msg: 'SYS: FRM_IOT_G4: Push firmware v4.2.1 deployment initialized for 12 nodes.', level: 'INFO' },
|
||||
{ t: '14:18:33', msg: 'AUTH: VAL_LOGIN_FAIL: Excessive attempts from 192.168.1.104. IP throttled.', level: 'ERROR' },
|
||||
].map((log, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: '12px', borderBottom: '1px solid rgba(255,255,255,0.02)', paddingBottom: '4px' }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>[{log.t}]</span>
|
||||
<span style={{
|
||||
color: log.level === 'ERROR' ? 'var(--alert-red)' : log.level === 'WARN' ? 'var(--warning-amber)' : 'var(--accent-cyan)',
|
||||
fontWeight: 700
|
||||
}}>{log.level}</span>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{log.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
242
src/pages/RoleLogin.tsx
Normal file
242
src/pages/RoleLogin.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams, NavLink } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
User as UserIcon,
|
||||
ArrowRight,
|
||||
Cpu,
|
||||
Radio,
|
||||
Activity,
|
||||
KeyRound,
|
||||
ShieldAlert,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Building2,
|
||||
Building,
|
||||
Stethoscope,
|
||||
Scan,
|
||||
ShoppingCart,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import { authApi } from '../api/auth';
|
||||
import './Login.css';
|
||||
|
||||
const roleMeta: Record<string, { title: string, subtitle: string, icon: any, color: string, dashboard: string }> = {
|
||||
'admin': {
|
||||
title: 'Admin Terminal',
|
||||
subtitle: 'CORE INFRASTRUCTURE',
|
||||
icon: Shield,
|
||||
color: 'var(--accent-cyan)',
|
||||
dashboard: '/'
|
||||
},
|
||||
'hospital-group': {
|
||||
title: 'Group Portal',
|
||||
subtitle: 'REGIONAL NETWORK',
|
||||
icon: Building2,
|
||||
color: '#3b82f6',
|
||||
dashboard: '/hospital-group'
|
||||
},
|
||||
'hospital': {
|
||||
title: 'Hospital Console',
|
||||
subtitle: 'FACILITY OPERATIONS',
|
||||
icon: Building,
|
||||
color: '#10b981',
|
||||
dashboard: '/hospital-console'
|
||||
},
|
||||
'provider': {
|
||||
title: 'Provider Access',
|
||||
subtitle: 'CLINICAL INTERFACE',
|
||||
icon: Stethoscope,
|
||||
color: '#8b5cf6',
|
||||
dashboard: '/provider'
|
||||
},
|
||||
'provider-react': {
|
||||
title: 'React Monitor',
|
||||
subtitle: 'ACTIVE TELEMETRY',
|
||||
icon: Activity,
|
||||
color: '#f59e0b',
|
||||
dashboard: '/provider-react'
|
||||
},
|
||||
'patient': {
|
||||
title: 'Patient Portal',
|
||||
subtitle: 'PERSONAL HEALTH',
|
||||
icon: UserIcon,
|
||||
color: '#ec4899',
|
||||
dashboard: '/patient-portal'
|
||||
},
|
||||
'scan-centre': {
|
||||
title: 'Diagnostic Hub',
|
||||
subtitle: 'IMAGING SERVICES',
|
||||
icon: Scan,
|
||||
color: '#06b6d4',
|
||||
dashboard: '/scan-centre'
|
||||
},
|
||||
'cart': {
|
||||
title: 'Cart Terminal',
|
||||
subtitle: 'MOBILE RESPONSE',
|
||||
icon: ShoppingCart,
|
||||
color: '#f43f5e',
|
||||
dashboard: '/cart'
|
||||
}
|
||||
};
|
||||
|
||||
export const RoleLogin = () => {
|
||||
const { role } = useParams<{ role: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const meta = roleMeta[role || 'admin'] || roleMeta['admin'];
|
||||
const Icon = meta.icon;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showError, setShowError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Set default credentials for dev convenience
|
||||
useEffect(() => {
|
||||
if (role === 'admin') {
|
||||
setUsername('admin');
|
||||
setPassword('Admin@123!');
|
||||
} else {
|
||||
setUsername(`${role?.replace('-', '_')}_user`);
|
||||
setPassword('User@123!');
|
||||
}
|
||||
}, [role]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setShowError('');
|
||||
|
||||
try {
|
||||
// Mocking successful login for the demo/request
|
||||
setTimeout(() => {
|
||||
localStorage.setItem('teleems_auth', 'true');
|
||||
localStorage.setItem('teleems_token', `dev-token-${role}`);
|
||||
localStorage.setItem('teleems_user', JSON.stringify({
|
||||
id: `user-${role}`,
|
||||
username: username,
|
||||
roles: [role?.toUpperCase().replace('-', '_') || 'USER'],
|
||||
metadata: { perspective: role }
|
||||
}));
|
||||
navigate(meta.dashboard);
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setShowError('Unable to connect to authentication server');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page" style={{ '--accent-color': meta.color } as React.CSSProperties}>
|
||||
<div className="login-grid-decor" />
|
||||
<div className="scanline" />
|
||||
<div className="login-overlay" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="login-card glass"
|
||||
style={{ borderColor: meta.color + '44' }}
|
||||
>
|
||||
<div className="login-header">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="login-logo"
|
||||
style={{ background: meta.color + '11', borderColor: meta.color }}
|
||||
>
|
||||
<Icon style={{ color: meta.color }} size={24} />
|
||||
</motion.div>
|
||||
|
||||
<h1 className="login-title">{meta.title}</h1>
|
||||
<p className="login-subtitle">{meta.subtitle} • SECURE ACCESS</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="login-form">
|
||||
<div className="input-group">
|
||||
<label className="input-label">Operator ID</label>
|
||||
<div className="input-wrapper">
|
||||
<UserIcon className="input-icon" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
className="login-input mono"
|
||||
placeholder="ID_ENTRY"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label className="input-label">Access Key</label>
|
||||
<div className="input-wrapper" style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Lock className="input-icon" size={18} />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="login-input mono"
|
||||
placeholder="********"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<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 }}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
disabled={isLoading}
|
||||
style={{ background: meta.color, color: meta.color === '#f8fafc' ? '#000' : '#fff' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Cpu className="spin" size={20} />
|
||||
) : (
|
||||
<>
|
||||
INITIALIZE SESSION
|
||||
<ArrowRight size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<AnimatePresence>
|
||||
{showError && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="security-badge" style={{ color: 'var(--alert-red)' }}>
|
||||
<ShieldAlert size={14} />
|
||||
<span>{showError.toUpperCase()}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="security-badge">
|
||||
<ShieldCheck size={14} style={{ color: meta.color }} />
|
||||
<span style={{ color: meta.color }}>PROTOCOL: {role?.toUpperCase()}_SECURE_v2</span>
|
||||
</div>
|
||||
|
||||
<div className="login-footer">
|
||||
<NavLink to="/launcher" style={{ color: 'var(--text-secondary)', textDecoration: 'none', fontSize: '0.8rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', opacity: 0.7 }}>
|
||||
← BACK TO PORTAL HUB
|
||||
</NavLink>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="login-status-indicators">
|
||||
<Radio size={14} className="pulse" style={{ color: meta.color }} />
|
||||
</div>
|
||||
|
||||
<div className="login-sys-log" style={{ opacity: 0.3 }}>
|
||||
<p>TERMINAL: {role?.toUpperCase()}-X9</p>
|
||||
<p>STATUS: READY</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
190
src/pages/SystemHealth.tsx
Normal file
190
src/pages/SystemHealth.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import { Zap, Server, Terminal } from 'lucide-react';
|
||||
import { Card } from '../components/Common';
|
||||
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
const cpuData = [
|
||||
{ time: '09:00', usage: 32 },
|
||||
{ time: '09:05', usage: 45 },
|
||||
{ time: '09:10', usage: 42 },
|
||||
{ time: '09:15', usage: 55 },
|
||||
{ time: '09:20', usage: 48 },
|
||||
{ time: '09:25', usage: 60 },
|
||||
];
|
||||
|
||||
export const SystemHealth: React.FC = () => {
|
||||
return (
|
||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 700 }}>Service Infrastructure & Health Monitor</h2>
|
||||
|
||||
{/* Connectivity Mesh Visualization */}
|
||||
<Card style={{ padding: '0', height: '300px', overflow: 'hidden', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: '15px', left: '20px', fontSize: '0.75rem', fontWeight: 700, color: 'var(--accent-cyan)' }}>MESH STATUS: OPTIMAL (RTT < 2ms)</div>
|
||||
<div style={{ width: '100%', height: '100%', background: 'radial-gradient(circle at center, rgba(59, 130, 246, 0.05) 0%, transparent 70%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ position: 'relative', width: '600px', height: '200px' }}>
|
||||
{/* Central Node */}
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: '60px', height: '60px', borderRadius: '50%', border: '2px solid var(--accent-cyan)', background: 'rgba(59, 130, 246, 0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 15px rgba(59, 130, 246, 0.2)' }}>
|
||||
<Zap size={24} color="var(--accent-cyan)" />
|
||||
</div>
|
||||
|
||||
{/* Orbital Nodes */}
|
||||
{[
|
||||
{ angle: 0, label: 'AUTH' },
|
||||
{ angle: 60, label: 'DISPATCH' },
|
||||
{ angle: 120, label: 'RTVS' },
|
||||
{ angle: 180, label: 'EPCR' },
|
||||
{ angle: 240, label: 'NOTIFY' },
|
||||
{ angle: 300, label: 'GATEWAY' },
|
||||
].map((node, i) => {
|
||||
const rad = (node.angle * Math.PI) / 180;
|
||||
const x = 50 + 40 * Math.cos(rad);
|
||||
const y = 50 + 40 * Math.sin(rad);
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<div style={{ position: 'absolute', top: `${y}%`, left: `${x}%`, width: '10px', height: '10px', background: 'var(--accent-green)', borderRadius: '50%', boxShadow: '0 0 10px var(--accent-green)' }}></div>
|
||||
<div style={{ position: 'absolute', top: `${y + 4}%`, left: `${x}%`, fontSize: '0.6rem', color: 'var(--text-secondary)', transform: 'translateX(-50%)' }}>{node.label}</div>
|
||||
{/* SVG Line to center */}
|
||||
<svg style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: -1 }}>
|
||||
<line x1="50%" y1="50%" x2={`${x}%`} y2={`${y}%`} stroke="rgba(59, 130, 246, 0.1)" strokeWidth="1" strokeDasharray="4 2" />
|
||||
</svg>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Services Grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '16px' }}>
|
||||
{[
|
||||
'Auth', 'Dispatch', 'RTVS', 'TeleLink', 'ePCR',
|
||||
'Notify', 'Fleet', 'Hospital', 'Analytics', 'Admin'
|
||||
].map((service) => (
|
||||
<Card key={service} style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<div style={{ padding: '6px', background: 'rgba(0,0,0,0.03)', borderRadius: '4px' }}>
|
||||
<Server size={14} color="var(--accent-cyan)" />
|
||||
</div>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--accent-green)', boxShadow: '0 0 10px var(--accent-green)' }}></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{service} Service</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '12px', fontSize: '0.65rem', color: 'var(--text-secondary)' }}>
|
||||
<span>Pods: 3/3</span>
|
||||
<span>v1.2.4</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '24px' }}>
|
||||
{/* Infrastructure Metrics */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<Card title="Global CPU Utilization (%)" subtitle="Aggregated Kubernetes Cluster Performance">
|
||||
<div style={{ height: '300px', marginTop: '12px', minWidth: 0 }}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={cpuData}>
|
||||
<defs>
|
||||
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--accent-green)" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="var(--accent-green)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="time" stroke="var(--text-secondary)" fontSize={12} />
|
||||
<YAxis stroke="var(--text-secondary)" fontSize={12} />
|
||||
<Tooltip contentStyle={{ background: 'var(--base-bg)', border: '1px solid var(--card-border)' }} />
|
||||
<Area type="monotone" dataKey="usage" stroke="var(--accent-green)" strokeWidth={3} fillOpacity={1} fill="url(#colorCpu)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||
<Card title="Active DB Connections">
|
||||
<div style={{ textAlign: 'center', padding: '10px 0' }}>
|
||||
<div className="mono" style={{ fontSize: '2.5rem', fontWeight: 700, color: 'var(--accent-cyan)' }}>142</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '4px' }}>AURORA GLOBAL (PRIMARY)</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Redis Hit Rate">
|
||||
<div style={{ textAlign: 'center', padding: '10px 0' }}>
|
||||
<div className="mono" style={{ fontSize: '2.5rem', fontWeight: 700, color: 'var(--accent-green)' }}>99.2%</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '4px' }}>ELASTICACHE CLUSTER</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CI/CD & Deployments */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<Card title="CI/CD Pipeline Status" subtitle="Recent Production Deploys">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{[
|
||||
{ svc: 'Dispatch-Service', ver: 'v2.1.0', status: 'SUCCESS', time: '2h ago' },
|
||||
{ svc: 'Auth-Service', ver: 'v1.4.2', status: 'SUCCESS', time: '5h ago' },
|
||||
{ svc: 'RTVS-Service', ver: 'v3.0.1', status: 'FAILED', time: '8h ago' },
|
||||
{ svc: 'Notify-Gateway', ver: 'v0.9.9', status: 'SUCCESS', time: '1d ago' },
|
||||
].map((dep, i) => (
|
||||
<div key={i} style={{ padding: '12px', background: 'rgba(0,0,0,0.02)', borderRadius: '6px', borderLeft: `3px solid ${dep.status === 'SUCCESS' ? 'var(--accent-green)' : 'var(--alert-red)'}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 700 }}>{dep.svc}</span>
|
||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: dep.status === 'SUCCESS' ? 'var(--accent-green)' : 'var(--alert-red)' }}>{dep.status}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '6px', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
|
||||
<span className="mono">{dep.ver}</span>
|
||||
<span>{dep.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button style={{ width: '100%', padding: '12px', background: 'transparent', border: '1px solid var(--card-border)', color: 'var(--text-secondary)', borderRadius: '6px', marginTop: '20px', fontSize: '0.8rem', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>
|
||||
<Terminal size={14} /> VIEW RUNNER LOGS
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
<Card title="SSL/TLS Expiry Monitor">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{[
|
||||
{ domain: 'api.teleems.in', days: 24, status: 'amber' },
|
||||
{ domain: 'rtvs.teleems.in', days: 120, status: 'green' },
|
||||
{ domain: 'telelink.in', days: 5, status: 'red' },
|
||||
].map((cert, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.8rem' }}>{cert.domain}</span>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
color: cert.status === 'red' ? 'var(--alert-red)' : cert.status === 'amber' ? 'var(--warning-amber)' : 'var(--accent-green)'
|
||||
}}>{cert.days} Days</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '32px' }}>
|
||||
<Card title="System-Wide Maintenance & Emergency Control" subtitle="Override global service state (Requires L3 Clearance)">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '24px', padding: '12px 0' }}>
|
||||
<div style={{ padding: '20px', background: 'rgba(239, 68, 68, 0.05)', border: '1px solid rgba(239, 68, 68, 0.2)', borderRadius: '12px' }}>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, color: 'var(--alert-red)', marginBottom: '8px' }}>Global Maintenance Mode</div>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginBottom: '16px' }}>Suspend all non-emergency routing and show maintenance landing page.</p>
|
||||
<button style={{ padding: '10px 16px', background: 'var(--alert-red)', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: 700, cursor: 'pointer', width: '100%' }}>ACTIVATE LOCKDOWN</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px', background: 'rgba(59, 130, 246, 0.05)', border: '1px solid var(--card-border)', borderRadius: '12px' }}>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, color: 'var(--accent-cyan)', marginBottom: '8px' }}>Cache Invalidation (Global)</div>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginBottom: '16px' }}>Flush all Redis clusters and rewarm critical platform metadata.</p>
|
||||
<button style={{ padding: '10px 16px', background: 'transparent', border: '1px solid var(--accent-cyan)', color: 'var(--accent-cyan)', borderRadius: '6px', fontWeight: 700, cursor: 'pointer', width: '100%' }}>FLUSH MEMORY</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px', background: 'rgba(245, 158, 11, 0.05)', border: '1px solid rgba(245, 158, 11, 0.2)', borderRadius: '12px' }}>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, color: 'var(--warning-amber)', marginBottom: '8px' }}>Traffic Throttling</div>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginBottom: '16px' }}>Enable rate-limiting for non-emergency API endpoints (e.g., Analytics).</p>
|
||||
<button style={{ padding: '10px 16px', background: 'transparent', border: '1px solid var(--warning-amber)', color: 'var(--warning-amber)', borderRadius: '6px', fontWeight: 700, cursor: 'pointer', width: '100%' }}>ENGAGE THROTTLE</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
172
src/pages/UserManagement.css
Normal file
172
src/pages/UserManagement.css
Normal file
@@ -0,0 +1,172 @@
|
||||
.user-mgmt-container {
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card-premium {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.stat-card-premium:hover {
|
||||
transform: translateY(-5px);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-card-premium::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.1), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.user-identity-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.05));
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.identity-table-premium {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
}
|
||||
|
||||
.identity-table-premium th {
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.identity-row {
|
||||
background: rgba(0, 0, 0, 0.01);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.identity-row:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
transform: scale(1.002);
|
||||
}
|
||||
|
||||
.identity-row td {
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.identity-row td:first-child {
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.02);
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
|
||||
.identity-row td:last-child {
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.02);
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for the table container */
|
||||
.table-scroll-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.table-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.table-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.pulse-active {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 10px var(--accent-green);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pulse-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border: 2px solid var(--accent-green);
|
||||
border-radius: 50%;
|
||||
animation: pulse-ring 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.5); opacity: 0.8; }
|
||||
100% { transform: scale(1.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-admin {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.role-super {
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: #FFD700;
|
||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
.role-cce {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #A855F7;
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
720
src/pages/UserManagement.tsx
Normal file
720
src/pages/UserManagement.tsx
Normal file
@@ -0,0 +1,720 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
UserPlus,
|
||||
MoreVertical,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
Radio,
|
||||
X,
|
||||
Check,
|
||||
ShieldAlert,
|
||||
Clock,
|
||||
MapPin,
|
||||
Edit2,
|
||||
LayoutDashboard} from 'lucide-react';
|
||||
import { Card } from '../components/Common';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { authApi } from '../api/auth';
|
||||
import './UserManagement.css';
|
||||
|
||||
type ManagementTab = 'RBAC_IDENTITY' | 'CCE_MANAGEMENT' | 'SECURITY_POLICIES';
|
||||
|
||||
export const UserManagement: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<ManagementTab>('RBAC_IDENTITY');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<any | null>(null);
|
||||
const [realUsers, setRealUsers] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [hospitalIdFilter, setHospitalIdFilter] = useState('');
|
||||
const [rolesList, setRolesList] = useState<any[]>([]);
|
||||
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
if (!token) return;
|
||||
const res = await authApi.getRoles(token);
|
||||
if (res.data && res.data.data) {
|
||||
setRolesList(res.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch roles:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const authStr = localStorage.getItem('teleems_auth');
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
if (!token) return;
|
||||
|
||||
const response = await authApi.getUsers(token);
|
||||
|
||||
if (response.status === 401 || response.message?.toLowerCase().includes('expired')) {
|
||||
localStorage.removeItem('teleems_auth');
|
||||
localStorage.removeItem('teleems_token');
|
||||
localStorage.removeItem('teleems_user');
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && Array.isArray(response.data)) {
|
||||
const mapped = response.data.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name || u.username,
|
||||
phone: u.phone,
|
||||
role: (Array.isArray(u.roles) && u.roles.length > 0) ? u.roles[0] : 'N/A',
|
||||
org: u.metadata?.organization?.company_name || u.metadata?.hospital?.name || u.metadata?.zone || 'TeleEMS Network',
|
||||
status: u.status || 'INACTIVE',
|
||||
mfa: !!u.mfaEnabled,
|
||||
hospitalId: u.hospitalId || u.organisationId || 'N/A',
|
||||
email: u.email || '',
|
||||
rawMetadata: u.metadata || {}
|
||||
}));
|
||||
setRealUsers(mapped);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadRoles();
|
||||
}, []);
|
||||
|
||||
const handleStatusToggle = async (user: any) => {
|
||||
try {
|
||||
const newStatus = user.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
|
||||
const payload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
phone: user.phone || '',
|
||||
status: newStatus,
|
||||
role: user.role,
|
||||
metadata: user.rawMetadata
|
||||
};
|
||||
|
||||
const res = await authApi.updateUser(user.id, payload, token);
|
||||
if (res.status === 401) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingUser) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
const payload = {
|
||||
name: editingUser.name,
|
||||
email: editingUser.email,
|
||||
phone: editingUser.phone || '',
|
||||
status: editingUser.status,
|
||||
role: editingUser.role,
|
||||
metadata: editingUser.rawMetadata
|
||||
};
|
||||
|
||||
const res = await authApi.updateUser(editingUser.id, payload, token);
|
||||
if (res.status === 401) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingUser(null);
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const [newUser, setNewUser] = useState({ name: '', role: 'FLEET_OPERATOR', org: '' });
|
||||
|
||||
const handleAddUser = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const id = `user-${Math.floor(Math.random() * 1000)}`;
|
||||
setRealUsers([...realUsers, { ...newUser, id, status: 'ACTIVE', mfa: false, hospitalId: 'PENDING' }]);
|
||||
setShowModal(false);
|
||||
setNewUser({ name: '', role: 'FLEET_OPERATOR', org: '' });
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = realUsers.length;
|
||||
const active = realUsers.filter(u => u.status === 'ACTIVE').length;
|
||||
const mfa = realUsers.filter(u => u.mfa).length;
|
||||
const admins = realUsers.filter(u => u.role?.includes('ADMIN')).length;
|
||||
return { total, active, mfa, admins };
|
||||
}, [realUsers]);
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container user-mgmt-container" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '2.8rem', fontWeight: 900, background: 'linear-gradient(135deg, var(--text-primary) 0%, var(--accent-cyan) 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', letterSpacing: '-0.03em' }}>
|
||||
Identity Hub
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginTop: '6px' }}>
|
||||
<div className="pulse-active"></div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
|
||||
Enterprise RBAC & Security Command Center
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass" style={{ padding: '6px', borderRadius: '14px', display: 'flex', gap: '6px', background: 'rgba(0,0,0,0.02)', border: '1px solid rgba(0,0,0,0.05)' }}>
|
||||
{[
|
||||
{ id: 'RBAC_IDENTITY', icon: Users, label: 'IDENTITIES' },
|
||||
{ id: 'CCE_MANAGEMENT', icon: Radio, label: 'CCE NODES' },
|
||||
{ id: 'SECURITY_POLICIES', icon: ShieldCheck, label: 'POLICIES' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
style={{
|
||||
padding: '10px 18px', borderRadius: '10px', border: 'none',
|
||||
background: activeTab === tab.id ? 'var(--accent-cyan)' : 'transparent',
|
||||
color: activeTab === tab.id ? '#fff' : 'var(--text-secondary)',
|
||||
fontWeight: 800, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.75rem',
|
||||
transition: 'all 0.3s ease',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
<tab.icon size={16} /> {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* STATS OVERVIEW */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card-premium">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Total Identities</div>
|
||||
<div style={{ fontSize: '2.4rem', fontWeight: 900, color: 'var(--text-primary)', marginTop: '4px' }}>{stats.total}</div>
|
||||
</div>
|
||||
<Users size={24} color="var(--accent-cyan)" />
|
||||
</div>
|
||||
<div style={{ marginTop: '12px', fontSize: '0.75rem', color: 'var(--accent-green)', fontWeight: 700 }}>↑ 12% from last month</div>
|
||||
</div>
|
||||
<div className="stat-card-premium">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Active Nodes</div>
|
||||
<div style={{ fontSize: '2.4rem', fontWeight: 900, color: 'var(--text-primary)', marginTop: '4px' }}>{stats.active}</div>
|
||||
</div>
|
||||
<Radio size={24} color="var(--accent-green)" />
|
||||
</div>
|
||||
<div style={{ marginTop: '12px', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>System availability: 100%</div>
|
||||
</div>
|
||||
<div className="stat-card-premium">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em' }}>MFA Adoption</div>
|
||||
<div style={{ fontSize: '2.4rem', fontWeight: 900, color: 'var(--text-primary)', marginTop: '4px' }}>{Math.round((stats.mfa/stats.total || 0) * 100)}%</div>
|
||||
</div>
|
||||
<ShieldCheck size={24} color="var(--accent-cyan)" />
|
||||
</div>
|
||||
<div style={{ marginTop: '12px', display: 'flex', gap: '4px' }}>
|
||||
{[1,2,3,4,5,6].map(i => <div key={i} style={{ flex: 1, height: '4px', borderRadius: '2px', background: i <= 5 ? 'var(--accent-cyan)' : 'rgba(0,0,0,0.03)' }}></div>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card-premium">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 800, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em' }}>Admin Control</div>
|
||||
<div style={{ fontSize: '2.4rem', fontWeight: 900, color: 'var(--text-primary)', marginTop: '4px' }}>{stats.admins}</div>
|
||||
</div>
|
||||
<ShieldAlert size={24} color="var(--warning-amber)" />
|
||||
</div>
|
||||
<div style={{ marginTop: '12px', fontSize: '0.75rem', color: 'var(--warning-amber)', fontWeight: 700 }}>Privileged Access Level</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NEW USER MODAL (Existing logic) */}
|
||||
<AnimatePresence>
|
||||
{showModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(255,255,255,0.85)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', backdropFilter: 'blur(8px)' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||
className="glass glow-cyan" style={{ width: '100%', maxWidth: '500px', padding: '40px', position: 'relative', border: '1px solid rgba(59,130,246,0.2)' }}>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{ position: 'absolute', right: '24px', top: '24px', background: 'transparent', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<h3 style={{ fontSize: '1.5rem', marginBottom: '8px', fontWeight: 800 }}>Provision New Identity</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '32px' }}>Assign roles and organizational hierarchy to a new platform user.</p>
|
||||
|
||||
<form onSubmit={handleAddUser} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--accent-cyan)', marginBottom: '10px', fontWeight: 700, textTransform: 'uppercase' }}>Full Legal Name</label>
|
||||
<input
|
||||
type="text" required value={newUser.name}
|
||||
onChange={(e) => setNewUser({...newUser, name: e.target.value})}
|
||||
style={{ width: '100%', padding: '14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--accent-cyan)', marginBottom: '10px', fontWeight: 700, textTransform: 'uppercase' }}>Role Assignment</label>
|
||||
<select
|
||||
value={newUser.role}
|
||||
onChange={(e) => setNewUser({...newUser, role: e.target.value})}
|
||||
style={{ width: '100%', padding: '14px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{rolesList.map((r: any) => (
|
||||
<option key={r.id} value={r.name}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--accent-cyan)', marginBottom: '10px', fontWeight: 700, textTransform: 'uppercase' }}>Organisation</label>
|
||||
<input
|
||||
type="text" required value={newUser.org}
|
||||
onChange={(e) => setNewUser({...newUser, org: e.target.value})}
|
||||
style={{ width: '100%', padding: '14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" style={{ width: '100%', padding: '16px', background: 'var(--accent-cyan)', border: 'none', borderRadius: '8px', fontWeight: 800, color: '#fff', cursor: 'pointer', marginTop: '10px', boxShadow: '0 8px 20px rgba(59,130,246,0.3)' }}>
|
||||
GENERATE SECURE CREDENTIALS
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* EDIT USER MODAL */}
|
||||
<AnimatePresence>
|
||||
{editingUser && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(255,255,255,0.85)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', backdropFilter: 'blur(8px)' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||
className="glass glow-amber" style={{ width: '100%', maxWidth: '500px', padding: '40px', position: 'relative', border: '1px solid rgba(255,191,0,0.3)' }}>
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
style={{ position: 'absolute', right: '24px', top: '24px', background: 'transparent', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<h3 style={{ fontSize: '1.5rem', marginBottom: '8px', fontWeight: 800 }}>Edit Identity Profile</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '32px' }}>Update platform access, organizational ties, and metadata nodes.</p>
|
||||
|
||||
<form onSubmit={handleEditSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--warning-amber)', marginBottom: '10px', fontWeight: 700, textTransform: 'uppercase' }}>Full Display Name</label>
|
||||
<input
|
||||
type="text" required value={editingUser.name}
|
||||
onChange={(e) => setEditingUser({...editingUser, name: e.target.value})}
|
||||
style={{ width: '100%', padding: '14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--warning-amber)', marginBottom: '10px', fontWeight: 700, textTransform: 'uppercase' }}>Email Address</label>
|
||||
<input
|
||||
type="email" required value={editingUser.email}
|
||||
onChange={(e) => setEditingUser({...editingUser, email: e.target.value})}
|
||||
style={{ width: '100%', padding: '14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--warning-amber)', marginBottom: '10px', fontWeight: 700, textTransform: 'uppercase' }}>Phone Contact</label>
|
||||
<input
|
||||
type="text" value={editingUser.phone || ''}
|
||||
onChange={(e) => setEditingUser({...editingUser, phone: e.target.value})}
|
||||
style={{ width: '100%', padding: '14px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--warning-amber)', marginBottom: '10px', fontWeight: 700, textTransform: 'uppercase' }}>Role Assignment</label>
|
||||
<select
|
||||
value={editingUser.role}
|
||||
onChange={(e) => setEditingUser({...editingUser, role: e.target.value})}
|
||||
style={{ width: '100%', padding: '14px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{rolesList.map((r: any) => (
|
||||
<option key={r.id} value={r.name}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" style={{ width: '100%', padding: '16px', background: 'var(--warning-amber)', border: 'none', borderRadius: '8px', fontWeight: 800, color: '#fff', cursor: 'pointer', marginTop: '10px' }}>
|
||||
PERSIST IDENTITY CHANGES
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'RBAC_IDENTITY' && (
|
||||
<motion.div key="rbac" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '32px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<Card title="Active Platform Users" subtitle="Manage credentials and multi-factor authentication status.">
|
||||
<div style={{ display: 'flex', gap: '20px', marginBottom: '20px', padding: '0 16px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontSize: '0.65rem', color: 'var(--accent-cyan)', marginBottom: '8px', fontWeight: 800 }}>SEARCH IDENTITY</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: '300px' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.65rem', color: 'var(--accent-cyan)', marginBottom: '8px', fontWeight: 800 }}>FILTER BY HOSPITAL / ORG ID</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Hospital ID..."
|
||||
value={hospitalIdFilter}
|
||||
onChange={(e) => setHospitalIdFilter(e.target.value)}
|
||||
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-primary)', fontSize: '0.8rem', fontFamily: 'monospace' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll-container" style={{ overflowX: 'auto', padding: '0 16px' }}>
|
||||
<table className="identity-table-premium">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User Identity</th>
|
||||
<th>Hospital / Org ID</th>
|
||||
<th>Role Node</th>
|
||||
<th>Contact</th>
|
||||
<th>Status & MFA</th>
|
||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{realUsers
|
||||
.filter(user => {
|
||||
const matchesSearch = (user.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(user.email || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesHospId = !hospitalIdFilter || String(user.hospitalId).includes(hospitalIdFilter);
|
||||
return matchesSearch && matchesHospId;
|
||||
})
|
||||
.map((user) => (
|
||||
<tr key={user.id} className="identity-row">
|
||||
<td>
|
||||
<div className="user-identity-cell">
|
||||
<div className="avatar-initials">{getInitials(user.name)}</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: '0.95rem', color: 'var(--text-primary)' }}>{user.name}</div>
|
||||
<div className="mono" style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{user.org}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="mono" style={{ fontSize: '0.75rem', color: 'var(--accent-cyan)', fontWeight: 800, background: 'rgba(59,130,246,0.05)', padding: '4px 8px', borderRadius: '4px', display: 'inline-block' }}>{user.hospitalId}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`role-badge ${user.role?.includes('ADMIN') ? 'role-super' : user.role?.includes('CCE') ? 'role-cce' : 'role-admin'}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', fontWeight: 500 }}>{user.phone || 'N/A'}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', opacity: 0.6, marginTop: '2px' }}>{user.email}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div className={user.status === 'ACTIVE' ? 'pulse-active' : ''} style={{ background: user.status === 'ACTIVE' ? 'var(--accent-green)' : 'var(--warning-amber)' }}></div>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.05em' }}>{user.status}</span>
|
||||
{user.mfa && <ShieldCheck size={14} color="var(--accent-green)" />}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setEditingUser(user)}
|
||||
className="btn-icon"
|
||||
style={{ color: 'var(--accent-cyan)' }}>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusToggle(user)}
|
||||
style={{
|
||||
padding: '6px 14px', background: user.status === 'ACTIVE' ? 'rgba(255, 59, 59, 0.1)' : 'rgba(0, 255, 136, 0.1)',
|
||||
border: `1px solid ${user.status === 'ACTIVE' ? 'rgba(255, 59, 59, 0.2)' : 'rgba(0, 255, 136, 0.2)'}`,
|
||||
borderRadius: '6px', fontSize: '0.65rem', color: user.status === 'ACTIVE' ? 'var(--alert-red)' : 'var(--accent-green)',
|
||||
fontWeight: 900, cursor: 'pointer', letterSpacing: '0.05em'
|
||||
}}>
|
||||
{user.status === 'ACTIVE' ? 'REVOKE' : 'RECOVER'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}>
|
||||
<button onClick={() => setShowModal(true)} style={{ padding: '12px 24px', background: 'var(--accent-cyan)', border: 'none', borderRadius: '8px', fontWeight: 800, color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<UserPlus size={18} /> PROVISION NEW IDENTITY
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Permissions Inheritance Matrix">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(0,0,0,0.02)' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Global Permission Node</th>
|
||||
<th style={{ padding: '12px' }}>Super_Admin</th>
|
||||
<th style={{ padding: '12px' }}>Hosp_Admin</th>
|
||||
<th style={{ padding: '12px' }}>Fleet_Ops</th>
|
||||
<th style={{ padding: '12px' }}>CCE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ node: 'CORE_SYSTEM_WRITE', roles: [1, 0, 0, 0] },
|
||||
{ node: 'ENTITY_ONBOARD_MGMT', roles: [1, 0, 0, 0] },
|
||||
{ node: 'INCIDENT_COMMAND_IO', roles: [1, 1, 1, 1] },
|
||||
{ node: 'MEDICAL_RECORDS_READ', roles: [1, 1, 0, 1] },
|
||||
{ node: 'FLEET_ROUTING_CONTROL', roles: [1, 0, 1, 1] },
|
||||
{ node: 'FINANCIAL_AUDIT_VIEW', roles: [1, 0, 0, 0] },
|
||||
].map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.02)' }}>
|
||||
<td style={{ padding: '12px', fontWeight: 700, color: 'var(--accent-cyan)' }}>{row.node}</td>
|
||||
{row.roles.map((enabled, index) => (
|
||||
<td key={index} style={{ padding: '12px', textAlign: 'center' }}>
|
||||
{enabled ? <Check size={16} color="var(--accent-green)" style={{ margin: '0 auto' }} /> : <X size={16} color="rgba(0,0,0,0.05)" style={{ margin: '0 auto' }} />}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<Card title="Security Pulse">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div style={{ background: 'rgba(255, 59, 59, 0.05)', border: '1px solid rgba(255, 59, 59, 0.2)', padding: '16px', borderRadius: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: 'var(--alert-red)' }}>
|
||||
<ShieldAlert size={18} />
|
||||
<span style={{ fontWeight: 800, fontSize: '0.85rem' }}>Failed Login Spike</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '8px' }}>Detected 42 failed attempts from IP 103.11.x.x in the last 10 minutes.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Global MFA Level</span>
|
||||
<span style={{ color: 'var(--accent-green)', fontWeight: 800, fontSize: '0.85rem' }}>92%</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '6px', background: 'rgba(0,0,0,0.05)', borderRadius: '3px' }}>
|
||||
<div style={{ width: '92%', height: '100%', background: 'linear-gradient(90deg, var(--accent-cyan), var(--accent-green))', borderRadius: '3px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="IP Whitelisting (Admin)">
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginBottom: '16px' }}>Restrict Super Admin access to specific corporate CIDRs.</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{['122.164.x.x (HQ Chennai)', '106.51.x.x (Secure VPN)'].map((ip, i) => (
|
||||
<div key={i} style={{ padding: '8px 12px', background: 'rgba(0,0,0,0.03)', borderRadius: '6px', fontSize: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="mono">{ip}</span>
|
||||
<X size={14} color="var(--text-secondary)" style={{ cursor: 'pointer' }} />
|
||||
</div>
|
||||
))}
|
||||
<button style={{ marginTop: '10px', padding: '8px', background: 'transparent', border: '1px dashed var(--card-border)', borderRadius: '6px', fontSize: '0.75rem', color: 'var(--text-secondary)', cursor: 'pointer' }}>+ ADD CIDR BLOCK</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'CCE_MANAGEMENT' && <CCEManagement key="cce" />}
|
||||
|
||||
{activeTab === 'SECURITY_POLICIES' && (
|
||||
<motion.div key="security" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<Card title="Global Authentication Policy">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '32px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: 'var(--accent-cyan)' }}>Session Settings</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Idle Timeout</span>
|
||||
<span className="mono" style={{ fontWeight: 700 }}>30 MINS</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Concurrent Logins</span>
|
||||
<span className="mono" style={{ fontWeight: 700 }}>RESTRICTED</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: 'var(--accent-cyan)' }}>Password Policy</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Expiry Cycle</span>
|
||||
<span className="mono" style={{ fontWeight: 700 }}>90 DAYS</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>History Enforcement</span>
|
||||
<span className="mono" style={{ fontWeight: 700 }}>5 RECENT</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: 'var(--accent-cyan)' }}>Account Lockout</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Max Failed Attempts</span>
|
||||
<span className="mono" style={{ fontWeight: 700 }}>5 RETRIES</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Lock Duration</span>
|
||||
<span className="mono" style={{ fontWeight: 700 }}>60 MINS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 5.6 Call Centre Management
|
||||
const CCEManagement = () => {
|
||||
const ccePools = [
|
||||
{ id: 'POOL_01', name: 'Strategic Dispatch Pool (Chennai)', cces: 12, standby: 4, active: 8, zones: ['Chennai', 'Kanchipuram'], shift: 'SH1 (06:00-14:00)' },
|
||||
{ id: 'POOL_02', name: 'Central Hub Pool (Coimbatore)', cces: 18, standby: 6, active: 10, zones: ['Coimbatore', 'Erode', 'Salem'], shift: 'SH1 (06:00-14:00)' },
|
||||
{ id: 'POOL_03', name: 'South Zone Transit (Madurai)', cces: 8, standby: 2, active: 6, zones: ['Madurai', 'Trichy'], shift: 'SH2 (14:00-22:00)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '24px' }}>
|
||||
{ccePools.map((pool, i) => (
|
||||
<Card key={i} className="hover-glow">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||
<div style={{ width: '40px', height: '40px', background: 'rgba(59, 130, 246, 0.1)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Radio size={20} color="var(--accent-cyan)" />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.65rem', fontWeight: 800, color: 'var(--text-secondary)' }} className="mono">{pool.id}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 800, marginBottom: '4px' }}>{pool.name}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--accent-cyan)', display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '16px' }}>
|
||||
<Clock size={12} /> {pool.shift}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px', marginBottom: '20px' }}>
|
||||
<div style={{ textAlign: 'center', background: 'rgba(0,0,0,0.03)', padding: '8px', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 800 }}>{pool.cces}</div>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Total</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', background: 'rgba(0,255,136,0.05)', padding: '8px', borderRadius: '8px', border: '1px solid rgba(0,255,136,0.1)' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 800, color: 'var(--accent-green)' }}>{pool.active}</div>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Online</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', background: 'rgba(255,184,0,0.05)', padding: '8px', borderRadius: '8px', border: '1px solid rgba(255,184,0,0.1)' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 800, color: 'var(--warning-amber)' }}>{pool.standby}</div>
|
||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Idle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', borderTop: '1px solid var(--card-border)', paddingTop: '16px' }}>
|
||||
{pool.zones.map(z => (
|
||||
<span key={z} style={{ fontSize: '0.65rem', padding: '2px 8px', background: 'rgba(0,0,0,0.03)', borderRadius: '4px', color: 'var(--text-secondary)' }}>{z}</span>
|
||||
))}
|
||||
<button style={{ marginLeft: 'auto', background: 'transparent', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer' }}><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '32px' }}>
|
||||
<Card title="Call Routing Configuration" subtitle="Geographic priority and load-based allocation.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{[
|
||||
{ region: 'District Cluster A', strategy: 'Round Robin', priority: 'High', load: '65%' },
|
||||
{ region: 'Metropolitan Core', strategy: 'Skill-based (Language)', priority: 'Emergency', load: '82%' },
|
||||
{ region: 'Northern Highways', strategy: 'Geographic Proximity', priority: 'Standard', load: '30%' },
|
||||
].map((row, i) => (
|
||||
<div key={i} style={{ padding: '16px', background: 'rgba(0,0,0,0.01)', borderRadius: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', border: '1px solid var(--card-border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<MapPin size={24} color="var(--accent-cyan)" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: '1rem' }}>{row.region}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Logic: {row.strategy}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontWeight: 800, color: 'var(--accent-cyan)' }}>{row.priority}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>LOAD: {row.load}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button style={{ padding: '14px', background: 'transparent', border: '1px dashed var(--card-border)', borderRadius: '12px', color: 'var(--text-secondary)', fontSize: '0.8rem', cursor: 'pointer', fontWeight: 600 }}>
|
||||
+ CONFIGURE NEW ROUTING DOMAIN
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="SLA & Escalation Timers">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Max Hold Time</span>
|
||||
<span className="mono" style={{ fontWeight: 800, color: 'var(--accent-cyan)' }}>45s</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '4px', background: 'rgba(0,0,0,0.03)', borderRadius: '2px' }}>
|
||||
<div style={{ width: '45%', height: '100%', background: 'var(--accent-cyan)', borderRadius: '2px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '0.85rem' }}>Max Dispatch Delay</span>
|
||||
<span className="mono" style={{ fontWeight: 800, color: 'var(--accent-cyan)' }}>120s</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '4px', background: 'rgba(0,0,0,0.03)', borderRadius: '2px' }}>
|
||||
<div style={{ width: '70%', height: '100%', background: 'var(--accent-cyan)', borderRadius: '2px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(59, 130, 246, 0.05)', padding: '16px', borderRadius: '12px', marginTop: '10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--accent-cyan)', marginBottom: '8px' }}>
|
||||
<AlertCircle size={16} />
|
||||
<span style={{ fontWeight: 800, fontSize: '0.8rem' }}>Auto-Escalation Rule</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', lineHeight: '1.4' }}>
|
||||
If a critical incident is not acknowledged within 90s, notify Supervisor and Regional Ops Manager via SMS/Push.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
207
src/pages/fleet/FleetAssets.tsx
Normal file
207
src/pages/fleet/FleetAssets.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Truck,
|
||||
FileText,
|
||||
Wrench,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
Fuel,
|
||||
Gauge
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../components/Common';
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
number: string;
|
||||
type: 'ALS' | 'BLS' | 'TRANSPORT';
|
||||
model: string;
|
||||
station: string;
|
||||
status: 'ACTIVE' | 'MAINTENANCE' | 'BREAKDOWN' | 'OFF_DUTY';
|
||||
docs: {
|
||||
rc: string;
|
||||
fc: string;
|
||||
insurance: string;
|
||||
permit: string;
|
||||
};
|
||||
lastService: string;
|
||||
nextService: string;
|
||||
fuel: number;
|
||||
}
|
||||
|
||||
const MOCK_FLEET: Vehicle[] = [
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
export const FleetAssets: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||||
|
||||
return (
|
||||
<div className="fleet-assets animate-in fade-in duration-500">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div className="glass" style={{ padding: '4px 16px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Search size={16} style={{ opacity: 0.5 }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by vehicle number or model..."
|
||||
style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.875rem', padding: '8px 0', width: '300px', outline: 'none' }}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-icon glass"><Filter size={18} /></button>
|
||||
</div>
|
||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Plus size={18} /> REGISTER NEW VEHICLE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '24px' }}>
|
||||
{/* Fleet Inventory Grid */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
|
||||
{MOCK_FLEET.map((v) => (
|
||||
<Card
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVehicle(v)}
|
||||
style={{
|
||||
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>
|
||||
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: 'var(--accent-cyan)', marginBottom: '4px' }}>{v.type} UNIT • {v.id}</div>
|
||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 800 }}>{v.number}</h3>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.5 }}>{v.model}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
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 style={{ display: 'flex', gap: '20px', marginBottom: '16px' }}>
|
||||
<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}`}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
{/* 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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
200
src/pages/fleet/FleetInventory.tsx
Normal file
200
src/pages/fleet/FleetInventory.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Truck,
|
||||
Activity,
|
||||
Archive,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../components/Common';
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'MEDICINE' | 'CONSUMABLE' | 'EQUIPMENT';
|
||||
totalStock: number;
|
||||
minStock: number;
|
||||
unit: string;
|
||||
expiringSoon: number;
|
||||
vehicles: { vehicleId: string; stock: number }[];
|
||||
}
|
||||
|
||||
const MOCK_INVENTORY: InventoryItem[] = [
|
||||
{ id: 'ITM-001', name: 'Adrenaline Injection 1mg', category: 'MEDICINE', totalStock: 450, minStock: 100, unit: 'AMPULES', expiringSoon: 24, vehicles: [{ vehicleId: 'V-001', stock: 10 }, { vehicleId: 'V-002', stock: 8 }] },
|
||||
{ id: 'ITM-002', name: 'Oxygen Cylinder (D-Type)', category: 'EQUIPMENT', totalStock: 32, minStock: 10, unit: 'UNITS', expiringSoon: 0, vehicles: [{ vehicleId: 'V-001', stock: 2 }, { vehicleId: 'V-002', stock: 1 }] },
|
||||
{ id: 'ITM-003', name: 'Surgical Gloves (Size 7)', category: 'CONSUMABLE', totalStock: 1200, minStock: 500, unit: 'PAIRS', expiringSoon: 0, vehicles: [{ vehicleId: 'V-001', stock: 50 }, { vehicleId: 'V-002', stock: 40 }] },
|
||||
{ id: 'ITM-004', name: 'IV Fluids (NS 500ml)', category: 'MEDICINE', totalStock: 85, minStock: 150, unit: 'BOTTLES', expiringSoon: 12, vehicles: [{ vehicleId: 'V-001', stock: 5 }, { vehicleId: 'V-002', stock: 3 }] },
|
||||
];
|
||||
|
||||
export const FleetInventory: React.FC = () => {
|
||||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||||
|
||||
return (
|
||||
<div className="fleet-inventory animate-in fade-in duration-500">
|
||||
{/* Top Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ color: 'var(--accent-cyan)' }}><Archive size={20} /></div>
|
||||
<span style={{ fontSize: '0.65rem', fontWeight: 900, color: 'var(--accent-green)' }}>+12%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 900 }}>1,248</div>
|
||||
<div style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>TOTAL SKUs</div>
|
||||
</div>
|
||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(239, 68, 68, 0.2)', background: 'rgba(239, 68, 68, 0.02)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ color: '#EF4444' }}><AlertTriangle size={20} /></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#EF4444' }}>14</div>
|
||||
<div style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>LOW STOCK ITEMS</div>
|
||||
</div>
|
||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(245, 158, 11, 0.2)', background: 'rgba(245, 158, 11, 0.02)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ color: '#F59E0B' }}><Clock size={20} /></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#F59E0B' }}>8</div>
|
||||
<div style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>EXPIRING (30D)</div>
|
||||
</div>
|
||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ color: 'var(--accent-cyan)' }}><BarChart3 size={20} /></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 900 }}>₹42.5k</div>
|
||||
<div style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>CONSUMPTION (MTD)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '24px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<Card title="Tactical Inventory Ledger">
|
||||
<div style={{ marginBottom: '16px', display: 'flex', gap: '12px' }}>
|
||||
<div className="glass" style={{ flex: 1, padding: '8px 16px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Search size={16} style={{ opacity: 0.5 }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter item master by name, category, or batch..."
|
||||
style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '0.8125rem', width: '100%', outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Plus size={16} /> ADD STOCK
|
||||
</button>
|
||||
</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: '12px' }}>Item Details</th>
|
||||
<th style={{ padding: '12px' }}>Category</th>
|
||||
<th style={{ padding: '12px' }}>Current Stock</th>
|
||||
<th style={{ padding: '12px' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_INVENTORY.map(item => (
|
||||
<tr
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
style={{
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
cursor: 'pointer',
|
||||
background: selectedItem?.id === item.id ? 'rgba(59, 130, 246, 0.05)' : 'transparent'
|
||||
}}
|
||||
className="hover-glow"
|
||||
>
|
||||
<td style={{ padding: '16px 12px' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.875rem' }}>{item.name}</div>
|
||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>SKU: {item.id}</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px 12px' }}>
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 600, opacity: 0.7 }}>{item.category}</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px 12px' }}>
|
||||
<div style={{ fontWeight: 800 }}>{item.totalStock} <span style={{ fontSize: '0.65rem', fontWeight: 500, opacity: 0.5 }}>{item.unit}</span></div>
|
||||
</td>
|
||||
<td style={{ padding: '16px 12px' }}>
|
||||
{item.totalStock < item.minStock ? (
|
||||
<div style={{ color: '#EF4444', fontSize: '0.65rem', fontWeight: 900, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<AlertTriangle size={12} /> CRITICAL
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#22C55E', fontSize: '0.65rem', fontWeight: 900 }}>OPTIMAL</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{selectedItem ? (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={selectedItem.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
>
|
||||
<Card title="Supply Intelligence" subtitle={`Ambulance-wise distribution for ${selectedItem.name}`}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div style={{ padding: '16px', 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, marginBottom: '4px' }}>STOCK GAP</div>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: 900, color: selectedItem.totalStock < selectedItem.minStock ? '#EF4444' : '#22C55E' }}>
|
||||
{selectedItem.totalStock - selectedItem.minStock}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px', 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, marginBottom: '4px' }}>REORDER POINT</div>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: 900 }}>{selectedItem.minStock}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ fontSize: '0.75rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--accent-cyan)', marginBottom: '12px' }}>Ambulance Stock Distribution</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{selectedItem.vehicles.map((v, i) => (
|
||||
<div key={i} style={{ padding: '12px', borderRadius: '12px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Truck size={14} style={{ opacity: 0.5 }} />
|
||||
<span style={{ fontSize: '0.8125rem', fontWeight: 700 }}>{v.vehicleId}</span>
|
||||
</div>
|
||||
<div style={{ fontWeight: 800, color: v.stock < 5 ? '#EF4444' : 'inherit' }}>
|
||||
{v.stock} {selectedItem.unit}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<button className="btn-ghost" style={{ width: '100%', fontSize: '0.75rem' }}>AUDIT LOG</button>
|
||||
<button className="btn-primary" style={{ width: '100%', fontSize: '0.75rem' }}>RESTOCK REQUEST</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<div className="glass" style={{ padding: '40px', borderRadius: '16px', textAlign: 'center', border: '1px dashed rgba(255,255,255,0.1)' }}>
|
||||
<Package size={32} style={{ opacity: 0.2, margin: '0 auto 16px' }} />
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '8px' }}>Select Supply Item</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>Inspect real-time stock levels across the fleet, track expiries, and manage replenishment requests.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
205
src/pages/fleet/FleetPersonnel.tsx
Normal file
205
src/pages/fleet/FleetPersonnel.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
UserPlus,
|
||||
Search,
|
||||
Filter,
|
||||
Users,
|
||||
Medal,
|
||||
Clock,
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
Mail,
|
||||
Phone,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
MoreVertical
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../components/Common';
|
||||
|
||||
interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'DRIVER' | 'EMT' | 'DOCTOR' | 'PARAMEDIC';
|
||||
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[] = [
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
export const FleetPersonnel: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'ALL' | 'DRIVER' | 'EMT' | 'DOCTOR'>('ALL');
|
||||
const [selectedStaff, setSelectedStaff] = useState<Staff | null>(null);
|
||||
|
||||
const filteredStaff = MOCK_STAFF.filter(s => activeTab === 'ALL' || s.role === activeTab);
|
||||
|
||||
return (
|
||||
<div className="fleet-personnel animate-in fade-in duration-500">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{['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>
|
||||
))}
|
||||
</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' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<Card style={{ padding: '0', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<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 style={{ fontWeight: 700 }}>{s.name}</div>
|
||||
<div style={{ fontSize: '0.65rem', opacity: 0.5 }}>ID: {s.id}</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>
|
||||
<h4 style={{ fontSize: '0.7rem', fontWeight: 800, textTransform: 'uppercase', opacity: 0.5, marginBottom: '8px' }}>Certifications</h4>
|
||||
<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>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<div className="glass" style={{ padding: '40px', borderRadius: '16px', textAlign: 'center', border: '1px dashed rgba(255,255,255,0.1)' }}>
|
||||
<Users size={32} style={{ opacity: 0.2, margin: '0 auto 16px' }} />
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '8px' }}>Select Personnel</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>View detailed performance metrics, licensing status, and shift history for your fleet crew.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
165
src/pages/fleet/FleetScheduling.tsx
Normal file
165
src/pages/fleet/FleetScheduling.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Users,
|
||||
Truck,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MoreVertical,
|
||||
Navigation,
|
||||
ShieldAlert
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../components/Common';
|
||||
|
||||
interface Assignment {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
shift: 'MORNING' | 'EVENING' | 'NIGHT';
|
||||
driver: string;
|
||||
emt: string;
|
||||
doctor?: string;
|
||||
status: 'SCHEDULED' | 'ON_DUTY' | 'HANDOVER_PENDING';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
const MOCK_ASSIGNMENTS: Assignment[] = [
|
||||
{ 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: 'AS-1002', vehicleId: 'V-002 (BLS)', shift: 'MORNING', driver: 'Suresh Kumar', emt: 'Amit Roy', status: 'ON_DUTY', startTime: '06:00', endTime: '14:00' },
|
||||
{ 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' },
|
||||
{ id: 'AS-1004', vehicleId: 'V-004 (TRANS)', shift: 'MORNING', driver: 'Ravi Teja', emt: 'Sneha Rao', status: 'HANDOVER_PENDING', startTime: '06:00', endTime: '14:00' },
|
||||
];
|
||||
|
||||
export const FleetScheduling: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
return (
|
||||
<div className="fleet-scheduling animate-in fade-in duration-500">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div className="glass" style={{ padding: '8px 16px', borderRadius: '12px', display: 'flex', alignItems: 'center', gap: '12px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<button className="btn-ghost-sm" style={{ padding: '4px' }}><ChevronLeft size={16} /></button>
|
||||
<span style={{ fontWeight: 800, fontSize: '0.875rem' }}>{selectedDate}</span>
|
||||
<button className="btn-ghost-sm" style={{ padding: '4px' }}><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{['DAY', 'WEEK', 'MONTH'].map(v => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Plus size={18} /> CREATE NEW ASSIGNMENT
|
||||
</button>
|
||||
</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>
|
||||
</td>
|
||||
<td style={{ padding: '16px 12px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 600 }}>P: {as.driver}</div>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.8 }}>E: {as.emt}</div>
|
||||
{as.doctor && <div style={{ fontSize: '0.75rem', color: 'var(--accent-green)' }}>D: {as.doctor}</div>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px 12px' }}>
|
||||
<span style={{
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 900,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
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)',
|
||||
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)'}`
|
||||
}}>{as.status.replace('_', ' ')}</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px 12px' }}>
|
||||
<button className="btn-ghost-sm"><MoreVertical size={14} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Conflict & Handover Panel */}
|
||||
<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 style={{ fontSize: '0.75rem', fontWeight: 800 }}>DOUBLE BOOKING DETECTED</div>
|
||||
<p style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: '4px' }}>Amit Roy (EMT) assigned to V-002 and V-005 in Evening Shift.</p>
|
||||
</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: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ padding: '16px', borderRadius: '12px', background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700 }}>V-004 Handover Checklist</span>
|
||||
<span style={{ fontSize: '0.65rem', color: '#F59E0B', fontWeight: 800 }}>4/6 TASKS</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.8 }}>
|
||||
<CheckCircle2 size={12} color="#22C55E" /> Fuel Tank Checked (100%)
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.8 }}>
|
||||
<CheckCircle2 size={12} color="#22C55E" /> Oxygen Level Verified
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', opacity: 0.5 }}>
|
||||
<Clock size={12} /> Narcotics Inventory Counter-sign
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary" style={{ width: '100%', marginTop: '16px', fontSize: '0.75rem' }}>RESOLVE HANDOVERS</button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
src/utils/auth.ts
Normal file
61
src/utils/auth.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Centralized authentication and session management utilities
|
||||
*/
|
||||
|
||||
export const logout = () => {
|
||||
console.log('Logging out: Clearing session data and redirecting...');
|
||||
localStorage.removeItem('teleems_auth');
|
||||
localStorage.removeItem('teleems_token');
|
||||
localStorage.removeItem('teleems_user');
|
||||
|
||||
// Use window.location for a hard redirect to ensure all states are cleared
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes a JWT and checks if it's expired
|
||||
*/
|
||||
export const isTokenExpired = (token: string): boolean => {
|
||||
if (!token || token === 'dev-super-token-2026' || token.startsWith('dev-token-') || token.startsWith('mock-')) return false; // Bypass for dev and mock tokens
|
||||
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
if (!base64Url) return true;
|
||||
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
|
||||
const { exp } = JSON.parse(jsonPayload);
|
||||
|
||||
if (!exp) return false;
|
||||
|
||||
// exp is in seconds, Date.now() in milliseconds
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return exp < currentTime;
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
return true; // Assume expired/invalid if error decoding
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the user is currently authenticated
|
||||
*/
|
||||
export const isAuthenticated = (): boolean => {
|
||||
const authFlag = localStorage.getItem('teleems_auth') === 'true';
|
||||
const token = localStorage.getItem('teleems_token');
|
||||
|
||||
if (!authFlag || !token) return false;
|
||||
|
||||
// If it's a JWT, check expiration
|
||||
if (token.includes('.') && isTokenExpired(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
vite.config.ts
Normal file
16
vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: 'https://teleems-auth-service.onrender.com',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user