Compare commits
2 Commits
Fleet_Oper
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8977f9e7fd | |||
| a1930c1bab |
22
scratch/tag_counter.py
Normal file
22
scratch/tag_counter.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
def count_tags(file_path):
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
div_open = content.count('<div')
|
||||||
|
div_close = content.count('</div>')
|
||||||
|
motion_div_open = content.count('<motion.div')
|
||||||
|
motion_div_close = content.count('</motion.div>')
|
||||||
|
paren_open = content.count('(')
|
||||||
|
paren_close = content.count(')')
|
||||||
|
brace_open = content.count('{')
|
||||||
|
brace_close = content.count('}')
|
||||||
|
|
||||||
|
print(f"div: open={div_open}, close={div_close}, diff={div_open - div_close}")
|
||||||
|
print(f"motion.div: open={motion_div_open}, close={motion_div_close}, diff={motion_div_open - motion_div_close}")
|
||||||
|
print(f"parentheses: open={paren_open}, close={paren_close}, diff={paren_open - paren_close}")
|
||||||
|
print(f"braces: open={brace_open}, close={brace_close}, diff={brace_open - brace_close}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
count_tags(sys.argv[1])
|
||||||
@@ -21,6 +21,7 @@ import { FleetLogin } from './pages/FleetLogin';
|
|||||||
import { FleetOperatorDashboard } from './pages/FleetOperatorDashboard';
|
import { FleetOperatorDashboard } from './pages/FleetOperatorDashboard';
|
||||||
import { PerspectiveLauncher } from './pages/PerspectiveLauncher';
|
import { PerspectiveLauncher } from './pages/PerspectiveLauncher';
|
||||||
import { RoleLogin } from './pages/RoleLogin';
|
import { RoleLogin } from './pages/RoleLogin';
|
||||||
|
import { HospitalLogin } from './pages/HospitalLogin';
|
||||||
import { ComingSoonPortal } from './pages/ComingSoonPortal';
|
import { ComingSoonPortal } from './pages/ComingSoonPortal';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@@ -43,10 +44,13 @@ const RoleProtectedRoute: React.FC<{
|
|||||||
|
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
const userRoles = Array.isArray(user?.roles) ? user.roles : [];
|
const userRoles = Array.isArray(user?.roles)
|
||||||
|
? user.roles.map((r: any) => String(r).toUpperCase().replace(/\s+/g, '_'))
|
||||||
|
: [];
|
||||||
const hasAccess = allowedRoles.some(role => userRoles.includes(role)) || userRoles.includes('CURESELECT_ADMIN');
|
const hasAccess = allowedRoles.some(role => userRoles.includes(role)) || userRoles.includes('CURESELECT_ADMIN');
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
|
console.log('[RBAC] Access Denied:', { required: allowedRoles, current: userRoles });
|
||||||
// Redirect to their respective "home" if they don't have access
|
// Redirect to their respective "home" if they don't have access
|
||||||
if (userRoles.includes('FLEET_OPERATOR')) return <Navigate to="/fleet-operator" replace />;
|
if (userRoles.includes('FLEET_OPERATOR')) return <Navigate to="/fleet-operator" replace />;
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
@@ -121,6 +125,7 @@ function AppContent() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PerspectiveLauncher />} />
|
<Route path="/" element={<PerspectiveLauncher />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/login/hospital" element={<HospitalLogin />} />
|
||||||
<Route path="/login/:role" element={<RoleLogin />} />
|
<Route path="/login/:role" element={<RoleLogin />} />
|
||||||
<Route path="/fleet-login" element={<FleetLogin />} />
|
<Route path="/fleet-login" element={<FleetLogin />} />
|
||||||
<Route path="/launcher" element={<PerspectiveLauncher />} />
|
<Route path="/launcher" element={<PerspectiveLauncher />} />
|
||||||
|
|||||||
@@ -25,60 +25,9 @@ export const apiClient = {
|
|||||||
defaultHeaders['Authorization'] = `Bearer ${authToken}`;
|
defaultHeaders['Authorization'] = `Bearer ${authToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MOCK BYPASS FOR DEMO SESSIONS ---
|
|
||||||
if (authToken && (
|
|
||||||
authToken.startsWith('mock-') ||
|
|
||||||
authToken.startsWith('dev-token-') ||
|
|
||||||
authToken === 'dev-super-token-2026'
|
|
||||||
)) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (endpoint.includes('/v1/incidents')) {
|
|
||||||
resolve({
|
|
||||||
status: 200,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: 'INC-MOCK-001',
|
|
||||||
category: 'MEDICAL',
|
|
||||||
severity: 'CRITICAL',
|
|
||||||
status: 'PENDING',
|
|
||||||
address: 'Sector 7G, Tactical Hub',
|
|
||||||
notes: 'High-priority mock incident for system validation.',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
gps_lat: 13.0827,
|
|
||||||
gps_lon: 80.2707,
|
|
||||||
patients: [{ name: 'Tactical Test', age: 34, gender: 'Male', symptoms: ['None'], triage_code: 'RED' }]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} else if (endpoint.includes('/v1/auth/users')) {
|
|
||||||
resolve({
|
|
||||||
status: 200,
|
|
||||||
data: [
|
|
||||||
{ id: 'u1', username: 'admin', roles: ['CURESELECT_ADMIN'], status: 'ACTIVE', email: 'admin@teleems.com' },
|
|
||||||
{ id: 'u2', username: 'fleet_op', roles: ['FLEET_OPERATOR'], status: 'ACTIVE', email: 'fleet@teleems.com' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} else if (endpoint.includes('/v1/auth/audit-logs')) {
|
|
||||||
resolve({
|
|
||||||
status: 200,
|
|
||||||
data: {
|
|
||||||
logs: [
|
|
||||||
{ id: 'l1', action: 'LOGIN_SUCCESS', createdAt: new Date().toISOString(), ipAddress: '127.0.0.1', user: { username: 'admin' } },
|
|
||||||
{ id: 'l2', action: 'INCIDENT_VIEW', createdAt: new Date().toISOString(), ipAddress: '127.0.0.1', user: { username: 'admin' } }
|
|
||||||
],
|
|
||||||
total: 2
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
resolve({ status: 200, data: [] });
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`;
|
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`;
|
||||||
|
console.log(`[API] ${options.method || 'GET'} ${url}`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { ...defaultHeaders, ...headers },
|
headers: { ...defaultHeaders, ...headers },
|
||||||
@@ -86,8 +35,8 @@ export const apiClient = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle session expiration
|
// Handle session expiration
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 && !url.includes('/auth/login')) {
|
||||||
console.warn('Unauthorized request detected. Triggering auto-logout...');
|
console.warn('Token expired or invalid. Triggering auto-logout...');
|
||||||
logout();
|
logout();
|
||||||
return null; // Return null as the app will redirect
|
return null; // Return null as the app will redirect
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const authApi = {
|
|||||||
return apiClient.post('/v1/auth/mfa/totp/verify', { totp_code: totpCode }, { token });
|
return apiClient.post('/v1/auth/mfa/totp/verify', { totp_code: totpCode }, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getProfile: async (token: string): Promise<AuthUser> => {
|
||||||
|
return apiClient.get('/v1/auth/me', { token });
|
||||||
|
},
|
||||||
|
|
||||||
getAuditLogs: async (token: string, limit = 20, offset = 0) => {
|
getAuditLogs: async (token: string, limit = 20, offset = 0) => {
|
||||||
return apiClient.get(`/v1/auth/audit-logs?limit=${limit}&offset=${offset}`, { token });
|
return apiClient.get(`/v1/auth/audit-logs?limit=${limit}&offset=${offset}`, { token });
|
||||||
},
|
},
|
||||||
|
|||||||
107
src/api/hospital.ts
Normal file
107
src/api/hospital.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { apiClient } from './apiClient';
|
||||||
|
import { authApi } from './auth';
|
||||||
|
import type { LoginResponse } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hospital-specific API module.
|
||||||
|
* Wraps auth endpoints with hospital-context logic.
|
||||||
|
*/
|
||||||
|
export const hospitalApi = {
|
||||||
|
/**
|
||||||
|
* Authenticate a hospital user via the real auth API.
|
||||||
|
*/
|
||||||
|
login: async (username: string, password: string): Promise<LoginResponse> => {
|
||||||
|
return authApi.login(username, password);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify MFA for hospital user.
|
||||||
|
*/
|
||||||
|
verifyMfa: async (mfaSessionToken: string, totpCode: string): Promise<LoginResponse> => {
|
||||||
|
return authApi.verifyMfa(mfaSessionToken, totpCode);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hospital profile data from the authenticated user metadata.
|
||||||
|
*/
|
||||||
|
getProfile: async (token: string) => {
|
||||||
|
return apiClient.get('/v1/hospital/ops/profile', { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get departments scoped to a specific hospital.
|
||||||
|
*/
|
||||||
|
getDepartments: async (token: string, hospitalId?: string) => {
|
||||||
|
const endpoint = hospitalId
|
||||||
|
? `/v1/hospital/departments?hospitalId=${hospitalId}`
|
||||||
|
: '/v1/hospital/departments';
|
||||||
|
return apiClient.get(endpoint, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get staff members scoped to a specific hospital.
|
||||||
|
*/
|
||||||
|
getStaff: async (token: string, hospitalId?: string) => {
|
||||||
|
const res = await authApi.getUsers(token);
|
||||||
|
if (res?.data && hospitalId) {
|
||||||
|
const filtered = (Array.isArray(res.data) ? res.data : []).filter(
|
||||||
|
(u: any) => u.hospitalId === hospitalId || u.organisationId === hospitalId
|
||||||
|
);
|
||||||
|
return { ...res, data: filtered };
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new department under a hospital.
|
||||||
|
*/
|
||||||
|
createDepartment: async (deptData: any, token: string) => {
|
||||||
|
return authApi.createDepartment(deptData, token);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a staff member under a hospital.
|
||||||
|
*/
|
||||||
|
registerStaff: async (staffData: any, token: string) => {
|
||||||
|
return authApi.registerUser(staffData, token);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile: async (payload: any, token: string) => {
|
||||||
|
return apiClient.patch('/v1/hospital/ops/profile', payload, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incoming operations/dispatches for the hospital.
|
||||||
|
*/
|
||||||
|
getIncomingOperations: async (token: string, hospitalId?: string, status?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (hospitalId) params.set('hospitalId', hospitalId);
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
const qs = params.toString();
|
||||||
|
const url = `/v1/hospital/ops/incoming${qs ? '?' + qs : ''}`;
|
||||||
|
return apiClient.get(url, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
admitPatient: async (patientId: string, departmentId: string, token: string) => {
|
||||||
|
return apiClient.patch(`/v1/hospital/ops/incoming/${patientId}/admit`, { departmentId, bedType: 'General' }, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all admitted patients for this hospital.
|
||||||
|
*/
|
||||||
|
getAdmissions: async (token: string, page?: number, limit?: number, status?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (page) params.set('page', String(page));
|
||||||
|
if (limit) params.set('limit', String(limit));
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
const qs = params.toString();
|
||||||
|
return apiClient.get(`/v1/hospital/ops/admissions${qs ? '?' + qs : ''}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discharge a patient by admission ID.
|
||||||
|
*/
|
||||||
|
dischargePatient: async (admissionId: string, token: string) => {
|
||||||
|
return apiClient.patch(`/v1/hospital/ops/admissions/${admissionId}/discharge`, {}, { token });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (parsed && typeof parsed === 'object') {
|
if (parsed && typeof parsed === 'object') {
|
||||||
parsed.roles = Array.isArray(parsed.roles)
|
parsed.roles = Array.isArray(parsed.roles)
|
||||||
? parsed.roles.map((r: any) => String(r).toUpperCase())
|
? parsed.roles.map((r: any) => String(r).toUpperCase().replace(/\s+/g, '_'))
|
||||||
: [];
|
: [];
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
const filteredNavItems = useMemo(() => {
|
const filteredNavItems = useMemo(() => {
|
||||||
const userRoles = Array.isArray(user.roles) ? user.roles : [];
|
const userRoles = Array.isArray(user.roles) ? user.roles : [];
|
||||||
const adminRoles = ['CURESELECT_ADMIN', 'ADMIN', 'SUPER_ADMIN', 'SUPERADMIN'];
|
const adminRoles = ['CURESELECT_ADMIN', 'ADMIN', 'SUPER_ADMIN', 'SUPERADMIN'];
|
||||||
const hasAdminRole = userRoles.some(r => adminRoles.includes(r));
|
const hasAdminRole = userRoles.some((r: string) => adminRoles.includes(r));
|
||||||
|
|
||||||
const filterItems = (items: NavItem[]): NavItem[] => {
|
const filterItems = (items: NavItem[]): NavItem[] => {
|
||||||
return items.filter(item => {
|
return items.filter(item => {
|
||||||
@@ -66,7 +66,9 @@ export const Sidebar: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
return filterItems(NAVIGATION_CONFIG);
|
const result = filterItems(NAVIGATION_CONFIG);
|
||||||
|
console.log('[DEBUG] userRoles:', userRoles, 'filteredItems:', result);
|
||||||
|
return result;
|
||||||
}, [user.roles]);
|
}, [user.roles]);
|
||||||
|
|
||||||
const renderNavItem = (item: NavItem, isSubItem = false) => {
|
const renderNavItem = (item: NavItem, isSubItem = false) => {
|
||||||
@@ -79,34 +81,23 @@ export const Sidebar: React.FC = () => {
|
|||||||
<div key={item.id} style={{ display: 'flex', flexDirection: 'column' }}>
|
<div key={item.id} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.path}
|
to={item.path}
|
||||||
style={({ isActive: linkActive }) => ({
|
className={({ isActive: linkActive }) =>
|
||||||
display: 'flex',
|
`${isSubItem ? 'sidebar-sub-item' : 'sidebar-nav-item'} ${(linkActive || isActive) ? 'active' : ''}`
|
||||||
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 }} />}
|
{!isSubItem && <Icon size={18} style={{ flexShrink: 0 }} />}
|
||||||
<span style={{
|
<span className="sidebar-label" style={{
|
||||||
fontWeight: (isActive || isParentActive) ? 700 : 500,
|
flex: 1,
|
||||||
fontSize: isSubItem ? '0.8rem' : '0.875rem',
|
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis'
|
||||||
flex: 1
|
|
||||||
}}>
|
}}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{hasSubItems && (
|
{hasSubItems && (
|
||||||
isParentActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
<span className="sidebar-label">
|
||||||
|
{isParentActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
@@ -117,7 +108,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
style={{ overflow: 'hidden', background: 'rgba(255,255,255,0.02)' }}
|
style={{ overflow: 'hidden', background: 'transparent' }}
|
||||||
>
|
>
|
||||||
{item.subItems?.map(sub => renderNavItem(sub, true))}
|
{item.subItems?.map(sub => renderNavItem(sub, true))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -128,7 +119,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="glass" style={{
|
<aside className="sidebar-premium" style={{
|
||||||
width: 'var(--sidebar-width)',
|
width: 'var(--sidebar-width)',
|
||||||
minWidth: 'var(--sidebar-width)',
|
minWidth: 'var(--sidebar-width)',
|
||||||
flexBasis: 'var(--sidebar-width)',
|
flexBasis: 'var(--sidebar-width)',
|
||||||
@@ -136,33 +127,30 @@ export const Sidebar: React.FC = () => {
|
|||||||
height: '100vh',
|
height: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
borderRight: '1px solid var(--card-border)',
|
|
||||||
background: 'var(--glass-bg)',
|
|
||||||
zIndex: 1100,
|
zIndex: 1100,
|
||||||
position: 'relative'
|
position: 'relative'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '20px 20px',
|
padding: '16px 20px',
|
||||||
borderBottom: '1px solid var(--card-border)',
|
borderBottom: '1px solid #edf2f7',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '30px',
|
width: '28px',
|
||||||
height: '30px',
|
height: '28px',
|
||||||
background: 'var(--accent-cyan)',
|
background: 'linear-gradient(135deg, #0ea5e9, #3b82f6)',
|
||||||
borderRadius: '6px',
|
borderRadius: '7px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
boxShadow: '0 0 12px var(--accent-cyan)',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<Zap size={18} color="#000" />
|
<Zap size={16} color="#ffffff" />
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 800, color: 'var(--accent-cyan)', margin: 0, letterSpacing: '-0.3px' }}>CureSelect</h2>
|
<h2 className="sidebar-label sidebar-logo-text" style={{ fontSize: '1.1rem', margin: 0 }}>CureSelect</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '12px 0', overflowY: 'auto', minHeight: 0 }} className="no-scrollbar">
|
<nav style={{ flex: 1, padding: '12px 0', overflowY: 'auto', minHeight: 0 }} className="no-scrollbar">
|
||||||
@@ -171,7 +159,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
borderTop: '1px solid var(--card-border)',
|
borderTop: '1px solid #edf2f7',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
@@ -189,42 +177,41 @@ export const Sidebar: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', minWidth: 0 }}>
|
<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={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0, overflow: 'hidden' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '32px',
|
width: '30px',
|
||||||
height: '32px',
|
height: '30px',
|
||||||
borderRadius: '50%',
|
borderRadius: '8px',
|
||||||
background: 'linear-gradient(135deg, var(--accent-cyan), var(--accent-green))',
|
background: 'linear-gradient(135deg, var(--accent-cyan), #3b82f6)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.7rem',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000',
|
color: '#fff',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>{initials}</div>
|
}}>{initials}</div>
|
||||||
<div style={{ minWidth: 0, overflow: 'hidden' }}>
|
<div className="sidebar-label" style={{ minWidth: 0, overflow: 'hidden' }}>
|
||||||
<div style={{ fontSize: '0.78rem', fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayName}</div>
|
<div style={{ fontSize: '0.8rem', fontWeight: 600, color: '#1e293b', 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 style={{ fontSize: '0.68rem', color: '#94a3b8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>ID: {displayId}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="hover-glow"
|
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(239, 68, 68, 0.1)',
|
background: '#fef2f2',
|
||||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
border: '1px solid #fecaca',
|
||||||
borderRadius: '8px',
|
borderRadius: '7px',
|
||||||
padding: '7px',
|
padding: '6px',
|
||||||
color: 'var(--alert-red)',
|
color: '#ef4444',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.15s',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
title="Sign Out"
|
title="Sign Out"
|
||||||
>
|
>
|
||||||
<LogOut size={15} />
|
<LogOut size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
203
src/components/TopBar.css
Normal file
203
src/components/TopBar.css
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/* --- PROFESSIONAL RESPONSIVE TOPBAR --- */
|
||||||
|
.topbar-container {
|
||||||
|
height: var(--topbar-height);
|
||||||
|
margin: 12px 12px 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
z-index: 900;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-snappy);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.home {
|
||||||
|
background: var(--accent-cyan-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 1;
|
||||||
|
width: clamp(160px, 20vw, 280px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px 7px 34px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-snappy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap input:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-cyan-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-time {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.bell-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--alert-red);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: var(--transition-snappy);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: var(--card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-cyan), #3b82f6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.nav-btn span { display: none; }
|
||||||
|
.clock-wrap { display: none; }
|
||||||
|
.search-wrap { width: 180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.topbar-container { margin: 8px 8px 0; padding: 0 12px; }
|
||||||
|
.search-wrap { display: none; }
|
||||||
|
.user-info { display: none; }
|
||||||
|
.nav-actions { gap: 4px; }
|
||||||
|
.topbar-right { gap: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.nav-actions { display: none; }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Search, Bell, Clock, LogOut, Home, ArrowLeft } from 'lucide-react';
|
import { Search, Bell, Clock, LogOut, Home, ArrowLeft } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { logout } from '../utils/auth';
|
import { logout } from '../utils/auth';
|
||||||
|
import './TopBar.css';
|
||||||
|
|
||||||
export const TopBar: React.FC = () => {
|
export const TopBar: React.FC = () => {
|
||||||
const [time, setTime] = useState(new Date());
|
const [time, setTime] = useState(new Date());
|
||||||
@@ -29,256 +30,87 @@ export const TopBar: React.FC = () => {
|
|||||||
|
|
||||||
const displayName = String(user.username || 'Admin');
|
const displayName = String(user.username || 'Admin');
|
||||||
const rawRole = Array.isArray(user.roles) ? (user.roles[0] || 'Administrator') : 'Administrator';
|
const rawRole = Array.isArray(user.roles) ? (user.roles[0] || 'Administrator') : 'Administrator';
|
||||||
// Shorten long role names for the header
|
|
||||||
const roleLabel = rawRole
|
const roleLabel = rawRole
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
.replace('CURESELECT ADMIN', 'CS ADMIN')
|
.replace('CURESELECT ADMIN', 'CS ADMIN')
|
||||||
.replace('HOSPITAL ADMIN', 'H. ADMIN')
|
.replace('HOSPITAL ADMIN', 'H. ADMIN')
|
||||||
.replace('FLEET OPERATOR', 'FLEET OPS')
|
.replace('FLEET OPERATOR', 'FLEET OPS')
|
||||||
.replace('STATION INCHARGE', 'STATION IC');
|
.replace('STATION INCHARGE', 'STATION IC');
|
||||||
|
|
||||||
const initials = displayName.substring(0, 2).toUpperCase() || 'AD';
|
const initials = displayName.substring(0, 2).toUpperCase() || 'AD';
|
||||||
|
|
||||||
const formattedTime = time.toLocaleTimeString('en-US', {
|
const formattedTime = time.toLocaleTimeString('en-US', {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
timeZone: 'Asia/Kolkata',
|
timeZone: 'Asia/Kolkata',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit'
|
||||||
second: '2-digit'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const tzLabel = 'IST';
|
const tzLabel = 'IST';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header className="topbar-container">
|
||||||
className="glass"
|
<div className="topbar-left">
|
||||||
style={{
|
<div className="nav-actions">
|
||||||
height: 'var(--topbar-height)',
|
|
||||||
margin: '16px 16px 0 16px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '0 20px',
|
|
||||||
gap: '16px',
|
|
||||||
border: '1px solid var(--card-border)',
|
|
||||||
background: 'var(--glass-bg)',
|
|
||||||
zIndex: 900,
|
|
||||||
borderRadius: '12px',
|
|
||||||
flexShrink: 0,
|
|
||||||
minWidth: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* ── LEFT: Search + status ────────────────────────────────── */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
|
||||||
{/* Global Navigation Controls */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
|
||||||
{/* Back Navigation Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="hover-glow"
|
className="nav-btn"
|
||||||
style={{
|
title="Go Back"
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
background: 'rgba(0, 0, 0, 0.03)',
|
|
||||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '8px 14px',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.8rem'
|
|
||||||
}}
|
|
||||||
title="Go to Previous Screen"
|
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
<span>BACK</span>
|
<span>BACK</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Home Navigation Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
className="hover-glow"
|
className="nav-btn home"
|
||||||
style={{
|
title="Return Home"
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
background: 'rgba(0, 209, 255, 0.1)',
|
|
||||||
border: '1px solid rgba(0, 209, 255, 0.3)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '8px 14px',
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.8rem'
|
|
||||||
}}
|
|
||||||
title="Return to Dashboard"
|
|
||||||
>
|
>
|
||||||
<Home size={16} />
|
<Home size={16} />
|
||||||
<span>HOME</span>
|
<span>HOME</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search bar */}
|
<div className="search-wrap">
|
||||||
<div style={{ position: 'relative', flexShrink: 0, width: 'clamp(160px, 22vw, 320px)' }}>
|
<Search size={14} className="search-icon" style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', opacity: 0.5 }} />
|
||||||
<Search
|
|
||||||
size={16}
|
|
||||||
style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)', pointerEvents: 'none' }}
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search operators, hospitals, incidents..."
|
placeholder="Search resources..."
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
background: 'rgba(0, 0, 0, 0.03)',
|
|
||||||
border: '1px solid var(--card-border)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '8px 10px 8px 36px',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
outline: 'none',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status pill — flexible, shrinks if needed */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '7px',
|
|
||||||
background: 'rgba(0, 255, 136, 0.08)',
|
|
||||||
padding: '5px 11px',
|
|
||||||
borderRadius: '20px',
|
|
||||||
border: '1px solid rgba(0, 255, 136, 0.18)',
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
maxWidth: '260px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: '7px', height: '7px', background: 'var(--accent-green)', borderRadius: '50%', boxShadow: '0 0 6px var(--accent-green)', flexShrink: 0 }} />
|
|
||||||
<span style={{ fontSize: '0.68rem', fontWeight: 700, color: 'var(--accent-green)', letterSpacing: '0.03em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
SYSTEM CONTROL PLANE HEALTHY
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── RIGHT: Clock + Bell + Profile ───────────────────────── */}
|
<div className="topbar-right">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px', flexShrink: 0 }}>
|
<div className="clock-wrap">
|
||||||
{/* Clock */}
|
<Clock size={14} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-secondary)', flexShrink: 0 }}>
|
<span className="clock-time">
|
||||||
<Clock size={15} />
|
{formattedTime} <small style={{ fontSize: '0.6rem', opacity: 0.6 }}>{tzLabel}</small>
|
||||||
<span
|
|
||||||
className="mono"
|
|
||||||
style={{ fontSize: '0.92rem', color: 'var(--text-primary)', fontWeight: 600, whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
{formattedTime}{' '}
|
|
||||||
<span style={{ fontSize: '0.65rem', color: 'var(--text-secondary)' }}>{tzLabel}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bell */}
|
<div className="notification-bell">
|
||||||
<div style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}>
|
<Bell size={18} />
|
||||||
<Bell size={18} style={{ color: 'var(--text-secondary)' }} />
|
<div className="bell-badge">3</div>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-4px',
|
|
||||||
right: '-5px',
|
|
||||||
width: '15px',
|
|
||||||
height: '15px',
|
|
||||||
background: 'var(--alert-red)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#fff',
|
|
||||||
boxShadow: '0 0 8px var(--alert-red)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
<div style={{ height: '20px', width: '1px', background: 'var(--card-border)' }} />
|
||||||
<div style={{ height: '20px', width: '1px', background: 'var(--card-border)', flexShrink: 0 }} />
|
|
||||||
|
|
||||||
{/* Profile */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
className="user-profile"
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
flexShrink: 0,
|
|
||||||
maxWidth: '220px',
|
|
||||||
}}
|
|
||||||
className="hover-glow"
|
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
title="Click to logout"
|
title="Click to logout"
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
<div className="avatar-circle">
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '30px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'linear-gradient(135deg, var(--accent-cyan), #0066ff)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'white',
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name + Role */}
|
<div className="user-info">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
<span className="user-name">{displayName}</span>
|
||||||
<span
|
<span className="user-role">{roleLabel} • Logout</span>
|
||||||
style={{
|
|
||||||
fontSize: '0.82rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.03em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{roleLabel} • Logout
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LogOut size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
|
<LogOut size={14} style={{ opacity: 0.5 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import {
|
|||||||
PhoneCall,
|
PhoneCall,
|
||||||
Navigation,
|
Navigation,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
LayoutGrid
|
LayoutGrid,
|
||||||
|
Video,
|
||||||
|
FileText,
|
||||||
|
TrendingUp,
|
||||||
|
BedDouble
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@@ -37,10 +41,10 @@ export const NAVIGATION_CONFIG: NavItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
label: 'Admin Dashboard',
|
label: 'Dashboard',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
path: '/',
|
path: '/',
|
||||||
roles: ['CURESELECT_ADMIN']
|
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'incidents',
|
id: 'incidents',
|
||||||
@@ -90,7 +94,19 @@ export const NAVIGATION_CONFIG: NavItem[] = [
|
|||||||
label: 'Hospital Ops',
|
label: 'Hospital Ops',
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
path: '/hospital-console',
|
path: '/hospital-console',
|
||||||
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT']
|
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'],
|
||||||
|
subItems: [
|
||||||
|
{ id: 'hosp-ed', label: 'ED Monitor', icon: Monitor, path: '/hospital-console?tab=ED_MONITOR', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-admissions', label: 'Admissions', icon: BedDouble, path: '/hospital-console?tab=ADMISSIONS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-bookings', label: 'Trip Management', icon: Activity, path: '/hospital-console?tab=BOOKINGS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-fleet', label: 'Fleet Visibility', icon: Truck, path: '/hospital-console?tab=FLEET', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-telelink', label: 'TeleLink Hub', icon: Video, path: '/hospital-console?tab=TELELINK', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-epcr', label: 'ePCR Records', icon: FileText, path: '/hospital-console?tab=EPCR', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-history', label: 'Patient Archive', icon: Database, path: '/hospital-console?tab=HISTORY', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-reports', label: 'Analytics', icon: TrendingUp, path: '/hospital-console?tab=REPORTS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-referrals', label: 'Referral Hub', icon: Hospital, path: '/hospital-console?tab=REFERRALS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
{ id: 'hosp-setup', label: 'Account & Setup', icon: Settings, path: '/hospital-console?tab=SETUP', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'master-data',
|
id: 'master-data',
|
||||||
|
|||||||
344
src/index.css
344
src/index.css
@@ -1,19 +1,62 @@
|
|||||||
@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');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--base-bg: #F8FAFC;
|
/* --- HSL DESIGN TOKENS (PROFESSIONAL MEDICAL PALETTE) --- */
|
||||||
--card-bg: #FFFFFF;
|
--hull-h: 220;
|
||||||
--card-border: rgba(59, 130, 246, 0.15);
|
--hull-s: 16%;
|
||||||
--accent-cyan: #3B82F6;
|
--hull-l: 97%;
|
||||||
--accent-green: #10B981;
|
|
||||||
--alert-red: #EF4444;
|
--hull-dark-h: 222;
|
||||||
--warning-amber: #F59E0B;
|
--hull-dark-s: 47%;
|
||||||
--text-primary: #1E293B;
|
--hull-dark-l: 11%;
|
||||||
--text-secondary: #64748B;
|
|
||||||
--glass-bg: rgba(255, 255, 255, 0.8);
|
/* Accents */
|
||||||
--glass-blur: blur(12px);
|
--accent-cyan-h: 199;
|
||||||
|
--accent-cyan-s: 89%;
|
||||||
|
--accent-cyan-l: 48%;
|
||||||
|
|
||||||
|
--accent-purple-h: 262;
|
||||||
|
--accent-purple-s: 83%;
|
||||||
|
--accent-purple-l: 58%;
|
||||||
|
|
||||||
|
--accent-green-h: 160;
|
||||||
|
--accent-green-s: 84%;
|
||||||
|
--accent-green-l: 39%;
|
||||||
|
|
||||||
|
/* Semantic Mappings */
|
||||||
|
--base-bg: #f8f9fb;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-border: #e5e7eb;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
--accent-cyan: hsl(var(--accent-cyan-h), var(--accent-cyan-s), var(--accent-cyan-l));
|
||||||
|
--accent-cyan-soft: hsla(var(--accent-cyan-h), var(--accent-cyan-s), var(--accent-cyan-l), 0.07);
|
||||||
|
--accent-cyan-glow: hsla(var(--accent-cyan-h), var(--accent-cyan-s), var(--accent-cyan-l), 0.25);
|
||||||
|
|
||||||
|
--accent-purple: hsl(var(--accent-purple-h), var(--accent-purple-s), var(--accent-purple-l));
|
||||||
|
--accent-purple-soft: hsla(var(--accent-purple-h), var(--accent-purple-s), var(--accent-purple-l), 0.08);
|
||||||
|
|
||||||
|
--accent-green: hsl(var(--accent-green-h), var(--accent-green-s), var(--accent-green-l));
|
||||||
|
--accent-green-soft: hsla(var(--accent-green-h), var(--accent-green-s), var(--accent-green-l), 0.08);
|
||||||
|
|
||||||
|
--alert-red: hsl(4, 90%, 58%);
|
||||||
|
--warning-amber: hsl(38, 92%, 50%);
|
||||||
|
|
||||||
|
--text-primary: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
|
||||||
|
/* Effects */
|
||||||
|
--hull-glass: rgba(255, 255, 255, 0.85);
|
||||||
|
--glass-blur: blur(16px);
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-premium: 0 20px 40px -12px rgba(0, 0, 0, 0.08);
|
||||||
|
--transition-snappy: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
--sidebar-width: 260px;
|
--sidebar-width: 260px;
|
||||||
--topbar-height: 70px;
|
--topbar-height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -23,16 +66,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background-color: var(--base-bg);
|
background-color: var(--base-bg);
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
|
||||||
background-size: 40px 40px;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
@@ -107,10 +148,17 @@ h1, h2, h3, h4 {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid Layouts */
|
/* 4K UI Limits Wrapper */
|
||||||
|
.page-content-wrapper {
|
||||||
|
max-width: 1920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Grid Layouts */
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
@@ -119,13 +167,49 @@ h1, h2, h3, h4 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1.5fr 1fr;
|
grid-template-columns: 1fr 1.5fr 1fr;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
height: calc(100% - 140px);
|
height: auto;
|
||||||
|
min-height: calc(100% - 140px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-grid > * {
|
.main-grid > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive Breakpoints */
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.main-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.main-grid > *:last-child {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 72px;
|
||||||
|
}
|
||||||
|
.sidebar-label {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.main-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.main-grid > *:last-child {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.stats-bar {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Pulse Animation */
|
/* Pulse Animation */
|
||||||
@keyframes pulse-red {
|
@keyframes pulse-red {
|
||||||
0% { transform: scale(1); opacity: 1; }
|
0% { transform: scale(1); opacity: 1; }
|
||||||
@@ -250,3 +334,221 @@ select, select option {
|
|||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
/* ─── PREMIUM ADMIN DASHBOARD STYLES ─── */
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.dashboard-header-premium {
|
||||||
|
position: relative;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, hsla(210, 40%, 98%, 0.8), hsla(0, 0%, 100%, 0.95));
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
box-shadow: 0 4px 24px -6px rgba(0, 0, 0, 0.03);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-premium h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
background: linear-gradient(to right, var(--text-primary), var(--accent-cyan));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-stat-card {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-stat-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
border-color: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-stat-icon-wrap {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 850;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-stat-sub {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-primary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-secondary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-health-card {
|
||||||
|
background: hsla(210, 40%, 98%, 0.8);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-health-card:hover {
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-status-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dashboard-primary-grid, .dashboard-secondary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ==========================================================================
|
||||||
|
GLOBAL SIDEBAR (PROFESSIONAL LIGHT DESIGN)
|
||||||
|
========================================================================== */
|
||||||
|
.sidebar-premium {
|
||||||
|
background: #ffffff !important;
|
||||||
|
border-right: 1px solid #edf2f7 !important;
|
||||||
|
box-shadow: 1px 0 8px rgba(0, 0, 0, 0.03);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-text {
|
||||||
|
background: linear-gradient(135deg, #0ea5e9, #2563eb);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 2px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
border-left: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item.active {
|
||||||
|
background: var(--accent-cyan-soft);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
border-left: 3px solid var(--accent-cyan);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item.active::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sub-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 20px 8px 48px;
|
||||||
|
margin: 1px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: none;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sub-item:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sub-item.active {
|
||||||
|
background: var(--accent-cyan-soft);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
border-left: 3px solid var(--accent-cyan);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sub-item.active::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -285,6 +285,54 @@ const CustomChartTooltip = ({ active, payload }: any) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- PREMIUM COMPONENTS ---
|
||||||
|
const PremiumStatCard: React.FC<{
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
subValue?: string;
|
||||||
|
icon: any;
|
||||||
|
trend?: { value: string; isUp: boolean };
|
||||||
|
glowColor: 'cyan' | 'green' | 'red' | 'amber';
|
||||||
|
pulse?: boolean;
|
||||||
|
}> = ({ label, value, subValue, icon: Icon, trend, glowColor, pulse }) => {
|
||||||
|
const colors = {
|
||||||
|
cyan: { bg: 'rgba(59, 130, 246, 0.1)', text: 'var(--accent-cyan)' },
|
||||||
|
green: { bg: 'rgba(16, 185, 129, 0.1)', text: 'var(--accent-green)' },
|
||||||
|
red: { bg: 'rgba(239, 68, 68, 0.1)', text: 'var(--alert-red)' },
|
||||||
|
amber: { bg: 'rgba(245, 158, 11, 0.1)', text: 'var(--warning-amber)' },
|
||||||
|
};
|
||||||
|
const theme = colors[glowColor];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="premium-stat-card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div className="premium-stat-icon-wrap" style={{ background: theme.bg, color: theme.text }}>
|
||||||
|
<Icon size={22} />
|
||||||
|
</div>
|
||||||
|
{pulse && <div className="status-pulse" style={{ background: theme.text }}></div>}
|
||||||
|
</div>
|
||||||
|
<div className="premium-stat-label">{label}</div>
|
||||||
|
<div className="premium-stat-value">
|
||||||
|
{value}
|
||||||
|
{subValue && <span className="premium-stat-sub">/ {subValue}</span>}
|
||||||
|
</div>
|
||||||
|
{trend && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: trend.isUp ? 'var(--accent-green)' : 'var(--alert-red)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px'
|
||||||
|
}}>
|
||||||
|
{trend.isUp ? '↑' : '↓'} {trend.value} <span style={{ color: 'var(--text-secondary)' }}>vs last hour</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Dashboard: React.FC = () => {
|
export const Dashboard: React.FC = () => {
|
||||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
@@ -306,7 +354,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
const token = localStorage.getItem('teleems_token') || '';
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
const roles = Array.isArray(user.roles) ? user.roles : [];
|
const roles = Array.isArray(user.roles) ? user.roles : [];
|
||||||
const isFleetOp = roles.includes('FLEET_OPERATOR');
|
const isFleetOp = roles.includes('FLEET_OPERATOR');
|
||||||
const orgName = user.metadata?.organization?.company_name || 'Fleet Operator';
|
const isHospitalAdmin = roles.some((r: string) => r.toUpperCase() === 'HOSPITAL_ADMIN' || r.toUpperCase() === 'HOSPITAL ADMIN');
|
||||||
|
const orgName = user.metadata?.organization?.company_name || (isHospitalAdmin ? 'Hospital' : 'Fleet Operator');
|
||||||
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
const [clickCoords, setClickCoords] = useState<{ lat: number, lng: number } | null>(null);
|
const [clickCoords, setClickCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||||
@@ -418,34 +467,34 @@ export const Dashboard: React.FC = () => {
|
|||||||
})) : [];
|
})) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '24px', paddingBottom: '40px' }}>
|
<div className="page-container" style={{ display: 'flex', flexDirection: 'column', gap: '0', paddingBottom: '40px' }}>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
<div className="dashboard-header-premium">
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ fontSize: '1.8rem', fontWeight: 800, background: 'linear-gradient(to right, var(--text-primary), var(--accent-cyan))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
<h2>
|
||||||
{isFleetOp ? `${orgName} Command` : 'Super Admin Command Center'}
|
{isFleetOp ? `${orgName} Command` : isHospitalAdmin ? `${orgName} Administration` : 'Super Admin Command Center'}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<span className="status-pulse" style={{ background: 'var(--accent-green)' }}></span>
|
<span className="status-pulse" style={{ background: 'var(--accent-green)' }}></span>
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', fontWeight: 600 }}>
|
||||||
Live platform telemetry synchronized at {lastUpdated.toLocaleTimeString()}
|
Live platform telemetry synchronized at {lastUpdated.toLocaleTimeString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
<button onClick={fetchData} className="glass hover-glow" style={{ padding: '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' }}>
|
<button onClick={fetchData} className="glass hover-glow" style={{ padding: '10px 20px', borderRadius: '8px', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', background: 'rgba(59,130,246,0.04)', color: 'var(--accent-cyan)', fontWeight: 700, fontSize: '0.8rem', border: '1px solid rgba(59,130,246,0.1)' }}>
|
||||||
<RefreshCw size={14} className={isLoading ? 'spin' : ''} /> REFRESH LIVE
|
<RefreshCw size={16} className={isLoading ? 'spin' : ''} /> REFRESH LIVE
|
||||||
</button>
|
</button>
|
||||||
<div className="glass mono" style={{ padding: '8px 16px', fontSize: '0.75rem', color: 'var(--accent-green)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<div className="glass mono" style={{ padding: '10px 20px', borderRadius: '8px', fontSize: '0.8rem', color: 'var(--accent-green)', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 700, border: '1px solid rgba(16,185,129,0.1)' }}>
|
||||||
<UsersIcon size={14} /> {fleetOperators.length} OPERATORS ACTIVE
|
<UsersIcon size={16} /> {fleetOperators.length} OPERATORS ACTIVE
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary Stats Bar */}
|
{/* Primary Stats Bar */}
|
||||||
<div className="stats-bar" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', marginBottom: 0 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '20px', marginBottom: '24px' }}>
|
||||||
<StatCard
|
<PremiumStatCard
|
||||||
label="Active Incidents"
|
label="Active Incidents"
|
||||||
value={activeIncidents.length}
|
value={activeIncidents.length}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
@@ -453,110 +502,73 @@ export const Dashboard: React.FC = () => {
|
|||||||
pulse={activeIncidents.length > 0}
|
pulse={activeIncidents.length > 0}
|
||||||
trend={{ value: '14%', isUp: true }}
|
trend={{ value: '14%', isUp: true }}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<PremiumStatCard
|
||||||
label="Operational Fleet"
|
label="Operational Fleet"
|
||||||
value={fleetOperators.length}
|
value={fleetOperators.length}
|
||||||
subValue={users.length.toString()}
|
subValue={users.length.toString()}
|
||||||
icon={Truck}
|
icon={Truck}
|
||||||
glowColor="cyan"
|
glowColor="cyan"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<PremiumStatCard
|
||||||
label="Dispatch SLA"
|
label="Dispatch SLA"
|
||||||
value="1.4s"
|
value="1.4s"
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
glowColor="green"
|
glowColor="green"
|
||||||
trend={{ value: '0.2s', isUp: false }}
|
trend={{ value: '0.2s', isUp: false }}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<PremiumStatCard
|
||||||
label="Critical Cases"
|
label="Critical Cases"
|
||||||
value={criticalIssues.length}
|
value={criticalIssues.length}
|
||||||
icon={HeartPulse}
|
icon={HeartPulse}
|
||||||
glowColor="amber"
|
glowColor="amber"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<PremiumStatCard
|
||||||
label="Live CCE nodes"
|
label="Live CCE nodes"
|
||||||
value={users.filter(u => u.roles?.includes('CCE')).length || 4}
|
value={users.filter(u => u.roles?.includes('CCE')).length || 4}
|
||||||
icon={Video}
|
icon={Video}
|
||||||
glowColor="cyan"
|
glowColor="cyan"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
|
||||||
label="Node Integrity"
|
|
||||||
value="100%"
|
|
||||||
icon={ShieldCheck}
|
|
||||||
glowColor="green"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Operational Grid */}
|
{/* Main Operational Grid: 2 Columns */}
|
||||||
<div className="main-grid" style={{ gridTemplateColumns: '1.5fr 1fr 1fr', height: 'auto', alignItems: 'start' }}>
|
<div className="dashboard-primary-grid">
|
||||||
{/* Real-time Heatmap */}
|
{/* Real-time Heatmap */}
|
||||||
<Card title="Global Incident Explorer" subtitle="System-wide incident history and live telemetry">
|
<Card title="Global Incident Explorer" subtitle="System-wide incident history and live telemetry" className="premium-health-card" style={{ padding: 0 }}>
|
||||||
|
<div style={{ padding: '24px', paddingBottom: 0 }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 800 }}>Global Incident Explorer</h3>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginBottom: '16px' }}>System-wide incident history and live telemetry</p>
|
||||||
|
</div>
|
||||||
<LiveIncidentMap incidents={incidents} />
|
<LiveIncidentMap incidents={incidents} />
|
||||||
|
|
||||||
{/* Legend Overlay (Absolute in Card) */}
|
<div style={{ position: 'absolute', bottom: '24px', right: '24px', zIndex: 1000, background: 'rgba(255,255,255,0.9)', padding: '12px 16px', borderRadius: '12px', border: '1px solid var(--card-border)', fontSize: '0.75rem', backdropFilter: 'blur(10px)', boxShadow: '0 8px 32px rgba(0,0,0,0.08)' }}>
|
||||||
<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: '10px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 10px #EF4444' }}></div>
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 10px #EF4444' }}></div>
|
||||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>CRITICAL</span>
|
<span style={{ fontWeight: 750, letterSpacing: '0.05em', color: 'var(--text-primary)' }}>CRITICAL</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px' }}>
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#F59E0B', boxShadow: '0 0 10px #F59E0B' }}></div>
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#F59E0B', boxShadow: '0 0 10px #F59E0B' }}></div>
|
||||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>ACTIVE (L/M)</span>
|
<span style={{ fontWeight: 750, letterSpacing: '0.05em', color: 'var(--text-primary)' }}>ACTIVE (L/M)</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#10B981', boxShadow: '0 0 10px #10B981' }}></div>
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#10B981', boxShadow: '0 0 10px #10B981' }}></div>
|
||||||
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>RESOLVED</span>
|
<span style={{ fontWeight: 750, letterSpacing: '0.05em', color: 'var(--text-primary)' }}>RESOLVED</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Governance Feed */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
|
||||||
<Card title="Governance Feed" subtitle="Real-time dispatch audit trail" style={{ height: '450px', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{incidents.slice(0, 8).map((inc) => (
|
|
||||||
<div key={inc.id} className="hover-glow" style={{
|
|
||||||
padding: '14px',
|
|
||||||
background: 'rgba(0,0,0,0.01)',
|
|
||||||
borderRadius: '10px',
|
|
||||||
borderLeft: `4px solid ${inc.severity === 'CRITICAL' ? 'var(--alert-red)' : inc.severity === 'HIGH' ? 'var(--warning-amber)' : 'var(--accent-cyan)'}`,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<span className="mono" style={{ fontWeight: 800, fontSize: '0.85rem', color: 'var(--accent-cyan)' }}>{inc.id.split('-').pop()}</span>
|
|
||||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{inc.address}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', marginTop: '4px', textTransform: 'uppercase', fontWeight: 600 }}>{inc.status}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 800 }}>{inc.eta_seconds ? `${Math.floor(inc.eta_seconds / 60)}m` : '--'}</div>
|
|
||||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)' }}>ETA</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{incidents.length === 0 && <div style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '40px 0' }}>No active incidents</div>}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Global Distribution & Health */}
|
{/* Global Distribution & Health */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', minHeight: 0 }}>
|
<Card title="Fleet Distribution" subtitle="Real-time system asset availability" className="premium-health-card">
|
||||||
<Card title="Fleet Distribution" subtitle="Real-time system asset availability">
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', justifyContent: 'space-between', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', justifyContent: 'space-between', minHeight: 0 }}>
|
||||||
<div style={{ height: '180px', position: 'relative', margin: '10px 0', minWidth: 0 }}>
|
<div style={{ height: '220px', position: 'relative', margin: '20px 0', minWidth: 0 }}>
|
||||||
<ResponsiveContainer width="100%" height="180">
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={fleetStatusData}
|
data={fleetStatusData}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={60}
|
innerRadius={70}
|
||||||
outerRadius={80}
|
outerRadius={95}
|
||||||
paddingAngle={8}
|
paddingAngle={6}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke="none"
|
stroke="none"
|
||||||
animationBegin={0}
|
animationBegin={0}
|
||||||
@@ -566,7 +578,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={entry.color}
|
fill={entry.color}
|
||||||
style={{ filter: `drop-shadow(0 0 8px ${entry.color}44)` }}
|
style={{ filter: `drop-shadow(0 0 12px ${entry.color}33)` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
@@ -583,35 +595,71 @@ export const Dashboard: React.FC = () => {
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
pointerEvents: 'none'
|
pointerEvents: 'none'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em' }}>Total</div>
|
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.1em' }}>Total</div>
|
||||||
<div style={{ fontSize: '1.6rem', fontWeight: 900, color: 'var(--text-primary)', lineHeight: 1 }}>
|
<div style={{ fontSize: '2.5rem', fontWeight: 900, color: 'var(--text-primary)', lineHeight: 1, margin: '4px 0' }}>
|
||||||
{fleetStatusData.reduce((acc, curr) => acc + curr.value, 0)}
|
{fleetStatusData.reduce((acc, curr) => acc + curr.value, 0)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.5rem', color: 'var(--accent-cyan)', fontWeight: 800, marginTop: '2px' }}>ASSETS</div>
|
<div style={{ fontSize: '0.6rem', color: 'var(--accent-cyan)', fontWeight: 850 }}>ASSETS</div>
|
||||||
</div>
|
</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)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', padding: '16px', background: 'rgba(0,0,0,0.02)', borderRadius: '12px', border: '1px solid rgba(0,0,0,0.05)' }}>
|
||||||
{fleetStatusData.map(item => {
|
{fleetStatusData.map(item => {
|
||||||
const total = fleetStatusData.reduce((acc, curr) => acc + curr.value, 0);
|
const total = fleetStatusData.reduce((acc, curr) => acc + curr.value, 0);
|
||||||
const percentage = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
const percentage = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div key={item.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 8px' }}>
|
<div key={item.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '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>
|
<div style={{ width: '10px', height: '10px', background: item.color, borderRadius: '3px', boxShadow: `0 0 12px ${item.color}66` }}></div>
|
||||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: 'var(--text-secondary)' }}>{item.name}</span>
|
<span style={{ fontSize: '0.75rem', fontWeight: 750, color: 'var(--text-secondary)' }}>{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="mono" style={{ fontSize: '0.7rem', fontWeight: 800, color: 'var(--text-primary)' }}>{percentage}%</span>
|
<span className="mono" style={{ fontSize: '0.85rem', fontWeight: 850, color: 'var(--text-primary)' }}>{percentage}%</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card title="System Performance" subtitle="Transaction density (30s avg)">
|
{/* Secondary Grid: Governance Feed and Performance */}
|
||||||
<div style={{ height: '140px', minWidth: 0, position: 'relative' }}>
|
<div className="dashboard-secondary-grid">
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
{/* Governance Feed */}
|
||||||
|
<Card title="Governance Feed" subtitle="Real-time dispatch audit trail" className="premium-health-card" style={{ height: '400px', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '16px' }}>
|
||||||
|
{incidents.slice(0, 8).map((inc) => (
|
||||||
|
<div key={inc.id} className="hover-glow" style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid var(--card-border)',
|
||||||
|
borderLeft: `4px solid ${inc.severity === 'CRITICAL' ? 'var(--alert-red)' : inc.severity === 'HIGH' ? 'var(--warning-amber)' : 'var(--accent-cyan)'}`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.02)'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<span className="mono" style={{ fontWeight: 850, fontSize: '0.9rem', color: 'var(--text-primary)' }}>#{inc.id.split('-').pop()}</span>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{inc.address}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', marginTop: '6px', textTransform: 'uppercase', fontWeight: 750, color: 'var(--accent-cyan)' }}>{inc.status}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<div style={{ fontSize: '0.9rem', fontWeight: 850, color: 'var(--text-primary)' }}>{inc.eta_seconds ? `${Math.floor(inc.eta_seconds / 60)}m` : '--'}</div>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontWeight: 700 }}>ETA</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{incidents.length === 0 && <div style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '40px 0', fontWeight: 600 }}>No active incidents</div>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="System Performance" subtitle="Transaction density (30s avg)" className="premium-health-card" style={{ height: '400px', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0, position: 'relative', marginTop: '16px' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={activityData}>
|
<AreaChart data={activityData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorSessions" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorSessions" x1="0" y1="0" x2="0" y2="1">
|
||||||
@@ -622,78 +670,89 @@ export const Dashboard: React.FC = () => {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '8px', fontSize: '12px' }}
|
contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '8px', fontSize: '12px' }}
|
||||||
/>
|
/>
|
||||||
<Area type="monotone" dataKey="count" stroke="var(--accent-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorSessions)" />
|
<Area type="monotone" dataKey="count" stroke="var(--accent-cyan)" strokeWidth={3} fillOpacity={1} fill="url(#colorSessions)" />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform DNA Section */}
|
{/* Platform DNA Section */}
|
||||||
<section style={{ display: 'grid', gridTemplateColumns: '2.5fr 1fr', gap: '24px' }}>
|
<section className="dashboard-primary-grid" style={{ marginBottom: '24px' }}>
|
||||||
<Card title="Platform Architecture & Compliance" subtitle="Global oversight of system DNA, security flags and ABDM synchronization.">
|
<Card title="Platform Architecture & Compliance" subtitle="Global oversight of system DNA, security flags and ABDM synchronization." className="premium-health-card" style={{ paddingBottom: '16px' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginTop: '8px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginTop: '16px' }}>
|
||||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
<div style={{ padding: '20px', borderRadius: '12px', background: '#0B1120', border: '1px solid #1E293B', color: '#fff', position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, var(--accent-cyan), transparent)' }}></div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Database size={22} color="var(--accent-cyan)" />
|
<Database size={20} color="var(--accent-cyan)" />
|
||||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-green)' }}>SYNCED</span>
|
<span className="health-status-badge" style={{ background: 'rgba(16,185,129,0.15)', color: 'var(--accent-green)' }}>
|
||||||
|
<span className="status-pulse" style={{ background: 'var(--accent-green)', width: '6px', height: '6px' }}></span> SYNCED
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Master Data</div>
|
<div style={{ fontSize: '1rem', fontWeight: 850, marginTop: '16px', letterSpacing: '-0.02em' }}>Master Data</div>
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>482 Triage rules active.</p>
|
<p style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '4px' }}>482 Triage rules active.</p>
|
||||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
<div style={{ height: '1px', background: '#1E293B', margin: '16px 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>
|
<button className="mono" style={{ width: '100%', padding: '10px', background: 'rgba(59,130,246,0.1)', border: '1px solid rgba(59,130,246,0.2)', color: '#60A5FA', fontSize: '0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 750, transition: 'all 0.2s' }}>MANAGE DNA</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
<div style={{ padding: '20px', borderRadius: '12px', background: '#0B1120', border: '1px solid #1E293B', color: '#fff', position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, var(--accent-green), transparent)' }}></div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<ShieldCheck size={22} color="var(--accent-green)" />
|
<ShieldCheck size={20} color="var(--accent-green)" />
|
||||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-green)' }}>100%</span>
|
<span className="health-status-badge" style={{ background: 'rgba(16,185,129,0.15)', color: 'var(--accent-green)' }}>
|
||||||
|
<span className="status-pulse" style={{ background: 'var(--accent-green)', width: '6px', height: '6px' }}></span> 100%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Compliance</div>
|
<div style={{ fontSize: '1rem', fontWeight: 850, marginTop: '16px', letterSpacing: '-0.02em' }}>Compliance</div>
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>HIPAA / ABDM verified.</p>
|
<p style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '4px' }}>HIPAA / ABDM verified.</p>
|
||||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
<div style={{ height: '1px', background: '#1E293B', margin: '16px 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>
|
<button className="mono" style={{ width: '100%', padding: '10px', background: 'rgba(16,185,129,0.1)', border: '1px solid rgba(16,185,129,0.2)', color: '#34D399', fontSize: '0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 750, transition: 'all 0.2s' }}>AUDIT LOGS</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
<div style={{ padding: '20px', borderRadius: '12px', background: '#0B1120', border: '1px solid #1E293B', color: '#fff', position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, var(--warning-amber), transparent)' }}></div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Settings size={22} color="var(--warning-amber)" />
|
<Settings size={20} color="var(--warning-amber)" />
|
||||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--warning-amber)' }}>STABLE</span>
|
<span className="health-status-badge" style={{ background: 'rgba(245,158,11,0.15)', color: 'var(--warning-amber)' }}>
|
||||||
|
<span className="status-pulse" style={{ background: 'var(--warning-amber)', width: '6px', height: '6px' }}></span> STABLE
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>System Logic</div>
|
<div style={{ fontSize: '1rem', fontWeight: 850, marginTop: '16px', letterSpacing: '-0.02em' }}>System Logic</div>
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>SLA thresholds active.</p>
|
<p style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '4px' }}>SLA thresholds active.</p>
|
||||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
<div style={{ height: '1px', background: '#1E293B', margin: '16px 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>
|
<button className="mono" style={{ width: '100%', padding: '10px', background: 'rgba(245,158,11,0.1)', border: '1px solid rgba(245,158,11,0.2)', color: '#FBBF24', fontSize: '0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 750, transition: 'all 0.2s' }}>CONFIGURE</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass hover-glow" style={{ padding: '20px', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
<div style={{ padding: '20px', borderRadius: '12px', background: '#0B1120', border: '1px solid #1E293B', color: '#fff', position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, var(--accent-cyan), transparent)' }}></div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Navigation size={22} color="var(--accent-cyan)" />
|
<Navigation size={20} color="var(--accent-cyan)" />
|
||||||
<span className="mono" style={{ fontSize: '0.6rem', color: 'var(--accent-cyan)' }}>ACTIVE</span>
|
<span className="health-status-badge" style={{ background: 'rgba(59,130,246,0.15)', color: '#60A5FA' }}>
|
||||||
|
<span className="status-pulse" style={{ background: '#60A5FA', width: '6px', height: '6px' }}></span> ACTIVE
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.9rem', fontWeight: 800, marginTop: '16px' }}>Network Hub</div>
|
<div style={{ fontSize: '1rem', fontWeight: 850, marginTop: '16px', letterSpacing: '-0.02em' }}>Network Hub</div>
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '6px' }}>Multi-zone sync active.</p>
|
<p style={{ fontSize: '0.75rem', color: '#94A3B8', marginTop: '4px' }}>Multi-zone sync active.</p>
|
||||||
<div style={{ height: '2px', background: 'rgba(0,0,0,0.02)', margin: '12px 0' }}></div>
|
<div style={{ height: '1px', background: '#1E293B', margin: '16px 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>
|
<button className="mono" style={{ width: '100%', padding: '10px', background: 'rgba(59,130,246,0.1)', border: '1px solid rgba(59,130,246,0.2)', color: '#60A5FA', fontSize: '0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 750, transition: 'all 0.2s' }}>NODES MAP</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Critical Task Cluster" style={{ background: 'rgba(255, 59, 59, 0.03)' }}>
|
<Card title="Critical Task Cluster" className="premium-health-card" style={{ background: 'linear-gradient(to bottom, #fff, hsla(0, 80%, 98%, 0.5))', border: '1px solid rgba(239,68,68,0.2)' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginTop: '16px' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Blood Link', status: 'Healthy', color: 'var(--accent-green)' },
|
{ label: 'Blood Link', status: 'Healthy', color: 'var(--accent-green)' },
|
||||||
{ label: 'Organ Registry', status: 'Healthy', color: 'var(--accent-green)' },
|
{ label: 'Organ Registry', status: 'Healthy', color: 'var(--accent-green)' },
|
||||||
{ label: 'Mortuary Sync', status: 'Delayed', color: 'var(--warning-amber)' },
|
{ label: 'Mortuary Sync', status: 'Delayed', color: 'var(--warning-amber)' },
|
||||||
{ label: 'Police V-Link', status: 'Healthy', color: 'var(--accent-green)' },
|
{ label: 'Police V-Link', status: 'Healthy', color: 'var(--accent-green)' },
|
||||||
].map((r, i) => (
|
].map((r, i) => (
|
||||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '8px', borderBottom: '1px solid rgba(0,0,0,0.02)' }}>
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '12px', borderBottom: '1px dashed var(--card-border)' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.8rem', fontWeight: 700 }}>{r.label}</div>
|
<div style={{ fontSize: '0.9rem', fontWeight: 800, color: 'var(--text-primary)' }}>{r.label}</div>
|
||||||
<div style={{ fontSize: '0.6rem', color: r.color }}>{r.status}</div>
|
<div style={{ fontSize: '0.7rem', color: r.color, fontWeight: 700, marginTop: '2px' }}>{r.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-pulse" style={{ background: r.color }}></div>
|
<div className="status-pulse" style={{ background: r.color, width: '10px', height: '10px' }}></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -702,8 +761,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
{/* SLA Ticker & Progress */}
|
{/* SLA Ticker & Progress */}
|
||||||
<div style={{ display: 'flex', gap: '24px', alignItems: 'stretch', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '24px', alignItems: 'stretch', flexWrap: 'wrap' }}>
|
||||||
<Card style={{ flex: 1, padding: '16px 24px' }}>
|
<Card className="premium-health-card" style={{ flex: 1, padding: '24px' }}>
|
||||||
<div style={{ display: 'grid', gap: '16px', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))' }}>
|
<div style={{ display: 'grid', gap: '24px', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Foundation', progress: 100 },
|
{ label: 'Foundation', progress: 100 },
|
||||||
{ label: 'MVP Core', progress: 100 },
|
{ label: 'MVP Core', progress: 100 },
|
||||||
@@ -712,11 +771,11 @@ export const Dashboard: React.FC = () => {
|
|||||||
{ label: 'Compliance', progress: 100 },
|
{ label: 'Compliance', progress: 100 },
|
||||||
].map((phase, i) => (
|
].map((phase, i) => (
|
||||||
<div key={phase.label} style={{ minWidth: 0 }}>
|
<div key={phase.label} style={{ minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.65rem', marginBottom: '6px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginBottom: '8px' }}>
|
||||||
<span style={{ fontWeight: 600 }}>{phase.label}</span>
|
<span style={{ fontWeight: 750, color: 'var(--text-primary)' }}>{phase.label}</span>
|
||||||
<span className="mono">{phase.progress}%</span>
|
<span className="mono" style={{ fontWeight: 850 }}>{phase.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: '4px', background: 'rgba(0,0,0,0.02)', borderRadius: '2px', overflow: 'hidden' }}>
|
<div style={{ height: '6px', background: 'rgba(0,0,0,0.05)', borderRadius: '3px', overflow: 'hidden' }}>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: `${phase.progress}%` }}
|
animate={{ width: `${phase.progress}%` }}
|
||||||
@@ -724,7 +783,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
background: phase.progress === 100 ? 'var(--accent-green)' : 'var(--accent-cyan)',
|
background: phase.progress === 100 ? 'var(--accent-green)' : 'var(--accent-cyan)',
|
||||||
boxShadow: `0 0 10px ${phase.progress === 100 ? 'rgba(0,255,136,0.3)' : 'rgba(0,212,255,0.3)'}`
|
boxShadow: `0 0 10px ${phase.progress === 100 ? 'rgba(0,255,136,0.5)' : 'rgba(0,212,255,0.5)'}`
|
||||||
}}
|
}}
|
||||||
></motion.div>
|
></motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -733,26 +792,35 @@ export const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="glass" style={{
|
<div style={{
|
||||||
minWidth: '300px',
|
minWidth: '300px',
|
||||||
flex: '1 1 320px',
|
flex: '1 1 320px',
|
||||||
padding: '16px',
|
padding: '16px 24px',
|
||||||
border: '1px solid var(--card-border)',
|
background: '#0B1120',
|
||||||
|
border: '1px solid #1E293B',
|
||||||
|
borderRadius: '16px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px',
|
gap: '16px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.1)'
|
||||||
}}>
|
}}>
|
||||||
<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="mono" style={{ fontSize: '0.75rem', fontWeight: 850, color: '#38BDF8', padding: '6px 12px', background: 'rgba(56, 189, 248, 0.15)', borderRadius: '6px', border: '1px solid rgba(56, 189, 248, 0.3)' }}>SLA TICKER</div>
|
||||||
<div className="no-scrollbar" style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', overflow: 'hidden', flex: 1, position: 'relative' }}>
|
<div className="no-scrollbar" style={{ fontSize: '0.85rem', color: '#94A3B8', overflow: 'hidden', flex: 1, position: 'relative' }}>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ x: [400, -800] }}
|
animate={{ x: [500, -1000] }}
|
||||||
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 30, repeat: Infinity, ease: "linear" }}
|
||||||
style={{ display: 'inline-block', whiteSpace: 'nowrap', fontWeight: 700 }}
|
style={{ display: 'inline-block', whiteSpace: 'nowrap', fontWeight: 750, letterSpacing: '0.02em' }}
|
||||||
>
|
>
|
||||||
HIPAA COMPLIANT ✅ | ABDM SYNCED ✅ | ISO 27001 AUDIT PASSED ✅ | PHI ENCRYPTED ✅ | DPDP ACT ALIGNED ✅ | END-TO-END TLS 1.3 ACTIVE ✅
|
<span style={{ color: '#fff' }}>HIPAA COMPLIANT</span> <span style={{ color: '#34D399' }}>✅</span> |
|
||||||
|
<span style={{ color: '#fff' }}>ABDM SYNCED</span> <span style={{ color: '#34D399' }}>✅</span> |
|
||||||
|
<span style={{ color: '#fff' }}>ISO 27001 AUDIT PASSED</span> <span style={{ color: '#34D399' }}>✅</span> |
|
||||||
|
<span style={{ color: '#fff' }}>PHI ENCRYPTED</span> <span style={{ color: '#34D399' }}>✅</span> |
|
||||||
|
<span style={{ color: '#fff' }}>DPDP ACT ALIGNED</span> <span style={{ color: '#34D399' }}>✅</span> |
|
||||||
|
<span style={{ color: '#fff' }}>END-TO-END TLS 1.3 ACTIVE</span> <span style={{ color: '#34D399' }}>✅</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ const LocationPickerMap: React.FC<{
|
|||||||
setIsLocating(false);
|
setIsLocating(false);
|
||||||
|
|
||||||
if (accuracy > 100) {
|
if (accuracy > 100) {
|
||||||
notify('GPS Warning', 'Location accuracy is low. Please manually adjust the pin.', 'info');
|
console.log('GPS Warning', 'Location accuracy is low. Please manually adjust the pin.', 'info');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -478,7 +478,7 @@ const LocationPickerMap: React.FC<{
|
|||||||
if (error.code === 1) msg = 'Location permission denied.';
|
if (error.code === 1) msg = 'Location permission denied.';
|
||||||
else if (error.code === 3) msg = 'Location request timed out.';
|
else if (error.code === 3) msg = 'Location request timed out.';
|
||||||
|
|
||||||
notify('GPS Error', msg, 'error');
|
console.log('GPS Error', msg, 'error');
|
||||||
setIsLocating(false);
|
setIsLocating(false);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1931,7 +1931,7 @@ const BrandRegistrationForm: React.FC<{ onSubmit: (data: any) => void; loading?:
|
|||||||
});
|
});
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2079,7 +2079,7 @@ const StationRegistrationForm: React.FC<{
|
|||||||
phone: ''
|
phone: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2440,7 +2440,7 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
const roles = u.roles?.map((r: any) => String(r).trim().toUpperCase()) || [];
|
const roles = u.roles?.map((r: any) => String(r).trim().toUpperCase()) || [];
|
||||||
const isOp = roles.includes('FLEET_OPERATOR') ||
|
const isOp = roles.includes('FLEET_OPERATOR') ||
|
||||||
roles.includes('FLEET OPERATOR') ||
|
roles.includes('FLEET OPERATOR') ||
|
||||||
roles.some(r => r.includes('FLEET') && r.includes('OPERATOR'));
|
roles.some((r: string) => r.includes('FLEET') && r.includes('OPERATOR'));
|
||||||
|
|
||||||
if (!isOp) return false;
|
if (!isOp) return false;
|
||||||
|
|
||||||
@@ -2559,13 +2559,13 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Registration Success:', result);
|
console.log('Registration Success:', result);
|
||||||
notify('Brand Registered', `Organisation "${data.metadata.organization.company_name}" has been onboarded.`, 'success');
|
console.log('Brand Registered', `Organisation "${data.metadata.organization.company_name}" has been onboarded.`, 'success');
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Registration failed:', error);
|
console.error('Registration failed:', error);
|
||||||
const isExpired = error.message.includes('Token has expired');
|
const isExpired = error.message.includes('Token has expired');
|
||||||
notify('Registration Failed', error.message, 'error');
|
console.log('Registration Failed', error.message, 'error');
|
||||||
|
|
||||||
if (isExpired) {
|
if (isExpired) {
|
||||||
localStorage.removeItem('teleems_auth');
|
localStorage.removeItem('teleems_auth');
|
||||||
@@ -2596,13 +2596,13 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Dispatch: Station Created Successfully:', stationId);
|
console.log('Dispatch: Station Created Successfully:', stationId);
|
||||||
notify('Station Created', `Station "${data.name}" is now active in the system.`, 'success');
|
console.log('Station Created', `Station "${data.name}" is now active in the system.`, 'success');
|
||||||
|
|
||||||
setRefreshKey(prev => prev + 1); // Trigger re-fetch of fleet owners/stations
|
setRefreshKey(prev => prev + 1); // Trigger re-fetch of fleet owners/stations
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Dispatch: Station Creation Failed:', error);
|
console.error('Dispatch: Station Creation Failed:', error);
|
||||||
notify('Station Error', error.message, 'error');
|
console.log('Station Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2628,13 +2628,13 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
throw new Error(result.message || `Failed to ${data.id ? 'update' : 'create'} vehicle.`);
|
throw new Error(result.message || `Failed to ${data.id ? 'update' : 'create'} vehicle.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
notify('Vehicle Updated', `Vehicle "${data.registration_number}" telemetry is now synced.`, 'success');
|
console.log('Vehicle Updated', `Vehicle "${data.registration_number}" telemetry is now synced.`, 'success');
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingVehicle(null);
|
setEditingVehicle(null);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Dispatch: Vehicle ${data.id ? 'Update' : 'Creation'} Failed:`, error);
|
console.error(`Dispatch: Vehicle ${data.id ? 'Update' : 'Creation'} Failed:`, error);
|
||||||
notify('Vehicle Error', error.message, 'error');
|
console.log('Vehicle Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2658,12 +2658,12 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Dispatch: Staff Registered Successfully');
|
console.log('Dispatch: Staff Registered Successfully');
|
||||||
notify('Staff Registered', `Personnel file for "${data.name}" has been created.`, 'success');
|
console.log('Staff Registered', `Personnel file for "${data.name}" has been created.`, 'success');
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Dispatch: Staff Registration Failed:', error);
|
console.error('Dispatch: Staff Registration Failed:', error);
|
||||||
notify('Staff Error', error.message, 'error');
|
console.log('Staff Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2682,11 +2682,11 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
throw new Error(result.message || 'Failed to create roster.');
|
throw new Error(result.message || 'Failed to create roster.');
|
||||||
}
|
}
|
||||||
|
|
||||||
notify('Roster Created', 'Crew assignment has been finalized.', 'success');
|
console.log('Roster Created', 'Crew assignment has been finalized.', 'success');
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Dispatch: Roster Creation Failed:', error);
|
console.error('Dispatch: Roster Creation Failed:', error);
|
||||||
notify('Roster Error', error.message, 'error');
|
console.log('Roster Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2705,12 +2705,12 @@ export const FleetDispatch: React.FC = () => {
|
|||||||
throw new Error(result.message || 'Failed to start shift.');
|
throw new Error(result.message || 'Failed to start shift.');
|
||||||
}
|
}
|
||||||
|
|
||||||
notify('Shift Started', 'Vehicle is now LIVE and tracking telemetry.', 'success');
|
console.log('Shift Started', 'Vehicle is now LIVE and tracking telemetry.', 'success');
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Dispatch: Shift Start Failed:', error);
|
console.error('Dispatch: Shift Start Failed:', error);
|
||||||
notify('Shift Error', error.message, 'error');
|
console.log('Shift Error', error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
634
src/pages/HospitalLogin.css
Normal file
634
src/pages/HospitalLogin.css
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
HOSPITAL LOGIN — Premium Medical Theme
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ─── PAGE SHELL ─────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(145deg, #F0FDFA 0%, #F8FAFC 40%, #ECFDF5 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── BACKGROUND EFFECTS ─────────────────────────────────────────────────────── */
|
||||||
|
.hosp-bg-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 30% 20%, rgba(16, 185, 129, 0.06) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse at 70% 80%, rgba(13, 148, 136, 0.04) 0%, transparent 55%),
|
||||||
|
linear-gradient(rgba(16, 185, 129, 0.02) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(16, 185, 129, 0.02) 1px, transparent 1px);
|
||||||
|
background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-bg-orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-1 {
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
top: -200px;
|
||||||
|
left: -100px;
|
||||||
|
background: radial-gradient(circle, rgba(16, 185, 129, 0.12) 0%, transparent 70%);
|
||||||
|
animation: orb-float 18s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-2 {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
bottom: -150px;
|
||||||
|
right: -100px;
|
||||||
|
background: radial-gradient(circle, rgba(13, 148, 136, 0.1) 0%, transparent 70%);
|
||||||
|
animation: orb-float 22s ease-in-out infinite alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-3 {
|
||||||
|
width: 350px;
|
||||||
|
height: 350px;
|
||||||
|
top: 50%;
|
||||||
|
left: 60%;
|
||||||
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.06) 0%, transparent 70%);
|
||||||
|
animation: orb-float 15s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orb-float {
|
||||||
|
0% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(60px, -40px) scale(1.1); }
|
||||||
|
100% { transform: translate(-30px, 30px) scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-scan-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(16, 185, 129, 0.4), transparent);
|
||||||
|
animation: hosp-scan 4s linear infinite;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hosp-scan {
|
||||||
|
0% { top: -2px; opacity: 0; }
|
||||||
|
10% { opacity: 1; }
|
||||||
|
90% { opacity: 1; }
|
||||||
|
100% { top: 100vh; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── FLOATING MEDICAL ICONS ─────────────────────────────────────────────────── */
|
||||||
|
.hosp-floating-icons {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-icon {
|
||||||
|
position: absolute;
|
||||||
|
color: rgba(16, 185, 129, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fi-1 { top: 15%; left: 12%; }
|
||||||
|
.fi-2 { top: 60%; right: 15%; }
|
||||||
|
.fi-3 { bottom: 20%; left: 20%; }
|
||||||
|
|
||||||
|
/* ─── LOGIN CARD ─────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-login-card {
|
||||||
|
width: 460px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
padding: 48px 44px 40px;
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(40px) saturate(1.6);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(16, 185, 129, 0.04),
|
||||||
|
0 30px 80px rgba(0, 0, 0, 0.07),
|
||||||
|
0 0 60px rgba(16, 185, 129, 0.04),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.03);
|
||||||
|
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-login-card.success-glow {
|
||||||
|
border-color: rgba(16, 185, 129, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(16, 185, 129, 0.15),
|
||||||
|
0 30px 80px rgba(16, 185, 129, 0.1),
|
||||||
|
0 0 100px rgba(16, 185, 129, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-card-accent {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 15%;
|
||||||
|
right: 15%;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, transparent, #10b981, #0d9488, transparent);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-card-accent::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 60%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.8), transparent);
|
||||||
|
animation: accent-shimmer 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes accent-shimmer {
|
||||||
|
0% { left: -60%; }
|
||||||
|
100% { left: 160%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── HEADER ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-logo-container {
|
||||||
|
position: relative;
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-logo-inner {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(13, 148, 136, 0.08));
|
||||||
|
border: 1.5px solid rgba(16, 185, 129, 0.35);
|
||||||
|
border-radius: 18px;
|
||||||
|
color: #0d9488;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: 0 0 30px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-logo-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border: 1.5px solid rgba(16, 185, 129, 0.15);
|
||||||
|
border-radius: 22px;
|
||||||
|
animation: ring-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ring-pulse {
|
||||||
|
0%, 100% { opacity: 0.5; transform: scale(1); }
|
||||||
|
50% { opacity: 0.15; transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-logo-pulse {
|
||||||
|
position: absolute;
|
||||||
|
inset: -8px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.08);
|
||||||
|
border-radius: 26px;
|
||||||
|
animation: ring-pulse 3s ease-in-out infinite 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: -0.7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-subtitle {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── FORM ───────────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: color 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border: 1.5px solid rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px 14px 48px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: 'Outfit', 'Inter', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input.mono {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
padding-left: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input::placeholder {
|
||||||
|
color: rgba(0, 0, 0, 0.25);
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input:focus {
|
||||||
|
background: rgba(16, 185, 129, 0.03);
|
||||||
|
border-color: rgba(16, 185, 129, 0.45);
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08), 0 0 20px rgba(16, 185, 129, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input:focus ~ .hosp-input-icon,
|
||||||
|
.hosp-input-wrap:focus-within .hosp-input-icon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input-focus-line {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
right: 50%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, #10b981, #0d9488);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-input:focus ~ .hosp-input-focus-line {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-eye-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
z-index: 2;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-eye-btn:hover { color: #10b981; }
|
||||||
|
|
||||||
|
/* ─── EXTRAS ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-extras {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-remember {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 500;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-remember input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1.5px solid rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-remember input[type="checkbox"]:checked {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border-color: rgba(16, 185, 129, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-remember input[type="checkbox"]:checked::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #10b981;
|
||||||
|
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-forgot {
|
||||||
|
color: #0d9488;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-forgot:hover { color: #10b981; }
|
||||||
|
|
||||||
|
/* ─── SUBMIT BUTTON ──────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 54px;
|
||||||
|
background: linear-gradient(135deg, #10b981, #0d9488);
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 30px rgba(16, 185, 129, 0.25),
|
||||||
|
0 0 0 1px rgba(16, 185, 129, 0.2);
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-submit-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, transparent 40%, rgba(255,255,255,0.15) 50%, transparent 60%);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-submit-btn:hover:not(:disabled)::before {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-submit-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 40px rgba(16, 185, 129, 0.35),
|
||||||
|
0 0 0 1px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-submit-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-submit-btn:disabled {
|
||||||
|
opacity: 0.8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-loader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-success-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── MFA ────────────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-mfa-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-back-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 12px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-back-btn:hover { color: #0d9488; }
|
||||||
|
|
||||||
|
/* ─── ERROR BADGE ────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-error-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: #EF4444;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
background: rgba(239, 68, 68, 0.06);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── SECURITY BADGE ─────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-security-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(16, 185, 129, 0.06);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── FOOTER ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-portal-link {
|
||||||
|
color: #64748b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-portal-link:hover {
|
||||||
|
color: #0d9488;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── STATUS BAR ─────────────────────────────────────────────────────────────── */
|
||||||
|
.hosp-status-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 28px;
|
||||||
|
right: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-status-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: dot-blink 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dot-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hosp-login-card {
|
||||||
|
padding: 40px 28px 32px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-title { font-size: 24px; }
|
||||||
|
.hosp-subtitle { font-size: 11px; letter-spacing: 1.5px; }
|
||||||
|
.float-icon { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.hosp-login-card {
|
||||||
|
padding: 32px 20px 28px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-title { font-size: 22px; }
|
||||||
|
.hosp-subtitle { font-size: 10px; }
|
||||||
|
.hosp-status-bar { display: none; }
|
||||||
|
|
||||||
|
.hosp-submit-btn {
|
||||||
|
height: 50px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosp-extras {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.hosp-login-card {
|
||||||
|
padding: 28px 16px 24px;
|
||||||
|
}
|
||||||
|
.hosp-logo-container { width: 56px; height: 56px; }
|
||||||
|
.hosp-logo-inner { width: 46px; height: 46px; border-radius: 14px; }
|
||||||
|
}
|
||||||
471
src/pages/HospitalLogin.tsx
Normal file
471
src/pages/HospitalLogin.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, NavLink } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ShieldCheck,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
ArrowRight,
|
||||||
|
Cpu,
|
||||||
|
Activity,
|
||||||
|
KeyRound,
|
||||||
|
ShieldAlert,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Building,
|
||||||
|
HeartPulse,
|
||||||
|
Stethoscope,
|
||||||
|
Monitor,
|
||||||
|
Wifi,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { hospitalApi } from '../api/hospital';
|
||||||
|
import './HospitalLogin.css';
|
||||||
|
|
||||||
|
export const HospitalLogin: React.FC = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [mfaCode, setMfaCode] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showError, setShowError] = useState('');
|
||||||
|
const [mfaSessionToken, setMfaSessionToken] = useState('');
|
||||||
|
const [tempUser, setTempUser] = useState<any>(null);
|
||||||
|
const [loginStep, setLoginStep] = useState<'login' | 'mfa'>('login');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setShowError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hospitalApi.login(username, password);
|
||||||
|
|
||||||
|
if (response.status === 201 || response.status === 200) {
|
||||||
|
if (response.data?.mfa_required) {
|
||||||
|
setMfaSessionToken(response.data.mfa_session_token || '');
|
||||||
|
setTempUser(response.data.user || null);
|
||||||
|
setLoginStep('mfa');
|
||||||
|
} else {
|
||||||
|
// Verify hospital role
|
||||||
|
const user = response.data?.user;
|
||||||
|
const roles = Array.isArray(user?.roles)
|
||||||
|
? user.roles.map((r: string) => r.toUpperCase())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const hasHospitalAccess = roles.some((r: string) =>
|
||||||
|
['HOSPITAL_ADMIN', 'HOSPITAL ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT', 'CURESELECT_ADMIN', 'ADMIN'].includes(r)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasHospitalAccess) {
|
||||||
|
setShowError('This account does not have hospital access privileges');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success animation then redirect
|
||||||
|
setLoginSuccess(true);
|
||||||
|
localStorage.setItem('teleems_auth', 'true');
|
||||||
|
localStorage.setItem('teleems_token', response.data.access_token || '');
|
||||||
|
localStorage.setItem('teleems_user', JSON.stringify(user || {}));
|
||||||
|
|
||||||
|
setTimeout(() => navigate('/'), 800);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let apiError = 'Invalid credentials. Please try again.';
|
||||||
|
const resAny = response as any;
|
||||||
|
if (resAny.error && typeof resAny.error === 'object') {
|
||||||
|
if (Array.isArray(resAny.error.details) && resAny.error.details.length > 0) {
|
||||||
|
apiError = resAny.error.details.join('; ');
|
||||||
|
} else {
|
||||||
|
apiError = resAny.error.message || apiError;
|
||||||
|
}
|
||||||
|
} else if (typeof resAny.error === 'string') {
|
||||||
|
apiError = resAny.error;
|
||||||
|
} else if (resAny.message) {
|
||||||
|
apiError = resAny.message;
|
||||||
|
} else if (resAny.detail) {
|
||||||
|
apiError = resAny.detail;
|
||||||
|
} else if (resAny.data) {
|
||||||
|
apiError = resAny.data.message || resAny.data.detail || resAny.data.error || apiError;
|
||||||
|
}
|
||||||
|
setShowError(apiError);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setShowError('Unable to reach hospital authentication server');
|
||||||
|
} finally {
|
||||||
|
if (!loginSuccess) setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMfaVerify = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setShowError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hospitalApi.verifyMfa(mfaSessionToken, mfaCode);
|
||||||
|
|
||||||
|
if (response.status === 201 || response.status === 200) {
|
||||||
|
const userToStore = response.data?.user || tempUser || {};
|
||||||
|
userToStore.mfa_enabled = true;
|
||||||
|
|
||||||
|
setLoginSuccess(true);
|
||||||
|
localStorage.setItem('teleems_auth', 'true');
|
||||||
|
localStorage.setItem('teleems_token', response.data.access_token || '');
|
||||||
|
localStorage.setItem('teleems_user', JSON.stringify(userToStore));
|
||||||
|
|
||||||
|
setTimeout(() => navigate('/'), 800);
|
||||||
|
} else {
|
||||||
|
let apiError = 'Invalid verification code';
|
||||||
|
const resAny = response as any;
|
||||||
|
if (resAny.error && typeof resAny.error === 'object') {
|
||||||
|
if (Array.isArray(resAny.error.details) && resAny.error.details.length > 0) {
|
||||||
|
apiError = resAny.error.details.join('; ');
|
||||||
|
} else {
|
||||||
|
apiError = resAny.error.message || apiError;
|
||||||
|
}
|
||||||
|
} else if (typeof resAny.error === 'string') {
|
||||||
|
apiError = resAny.error;
|
||||||
|
} else if (resAny.message) {
|
||||||
|
apiError = resAny.message;
|
||||||
|
} else if (resAny.detail) {
|
||||||
|
apiError = resAny.detail;
|
||||||
|
} else if (resAny.data) {
|
||||||
|
apiError = resAny.data.message || resAny.data.detail || resAny.data.error || apiError;
|
||||||
|
}
|
||||||
|
setShowError(apiError);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setShowError('MFA verification failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
if (!loginSuccess) setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hosp-login-page">
|
||||||
|
{/* Background effects */}
|
||||||
|
<div className="hosp-bg-grid" />
|
||||||
|
<div className="hosp-bg-orb orb-1" />
|
||||||
|
<div className="hosp-bg-orb orb-2" />
|
||||||
|
<div className="hosp-bg-orb orb-3" />
|
||||||
|
<div className="hosp-scan-line" />
|
||||||
|
|
||||||
|
{/* Floating medical icons */}
|
||||||
|
<div className="hosp-floating-icons">
|
||||||
|
<motion.div
|
||||||
|
className="float-icon fi-1"
|
||||||
|
animate={{ y: [-10, 10, -10], rotate: [0, 5, -5, 0] }}
|
||||||
|
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<HeartPulse size={20} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="float-icon fi-2"
|
||||||
|
animate={{ y: [10, -10, 10], rotate: [0, -5, 5, 0] }}
|
||||||
|
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<Stethoscope size={18} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="float-icon fi-3"
|
||||||
|
animate={{ y: [-8, 12, -8] }}
|
||||||
|
transition={{ duration: 7, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<Activity size={16} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login card */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={loginStep + (loginSuccess ? '-success' : '')}
|
||||||
|
initial={{ opacity: 0, y: 30, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -20, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className={`hosp-login-card ${loginSuccess ? 'success-glow' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Card top accent */}
|
||||||
|
<div className="hosp-card-accent" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="hosp-login-header">
|
||||||
|
<motion.div
|
||||||
|
className="hosp-logo-container"
|
||||||
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
|
||||||
|
>
|
||||||
|
<div className="hosp-logo-ring" />
|
||||||
|
<div className="hosp-logo-inner">
|
||||||
|
<Building size={26} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div className="hosp-logo-pulse" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
className="hosp-title"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
{loginStep === 'login' ? 'Hospital Console' : 'Verify Identity'}
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
className="hosp-subtitle"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
>
|
||||||
|
{loginStep === 'login'
|
||||||
|
? 'Secure Clinical Operations Portal'
|
||||||
|
: 'Enter your authenticator code'}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login form */}
|
||||||
|
{loginStep === 'login' ? (
|
||||||
|
<form onSubmit={handleLogin} className="hosp-form">
|
||||||
|
<motion.div
|
||||||
|
className="hosp-input-group"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.35 }}
|
||||||
|
>
|
||||||
|
<label className="hosp-label">Hospital ID</label>
|
||||||
|
<div className="hosp-input-wrap">
|
||||||
|
<User className="hosp-input-icon" size={18} />
|
||||||
|
<input
|
||||||
|
id="hospital-username"
|
||||||
|
type="text"
|
||||||
|
className="hosp-input"
|
||||||
|
placeholder="Enter username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
<div className="hosp-input-focus-line" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="hosp-input-group"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.45 }}
|
||||||
|
>
|
||||||
|
<label className="hosp-label">Access Key</label>
|
||||||
|
<div className="hosp-input-wrap">
|
||||||
|
<Lock className="hosp-input-icon" size={18} />
|
||||||
|
<input
|
||||||
|
id="hospital-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className="hosp-input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hosp-eye-btn"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
<div className="hosp-input-focus-line" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="hosp-extras"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<label className="hosp-remember">
|
||||||
|
<input type="checkbox" />
|
||||||
|
<span className="hosp-check-mark" />
|
||||||
|
Keep session active
|
||||||
|
</label>
|
||||||
|
<a href="#" className="hosp-forgot">Reset credentials?</a>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
className="hosp-submit-btn"
|
||||||
|
disabled={isLoading || loginSuccess}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.55 }}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
{loginSuccess ? (
|
||||||
|
<motion.div
|
||||||
|
className="hosp-success-check"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={22} />
|
||||||
|
<span>ACCESS GRANTED</span>
|
||||||
|
</motion.div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="hosp-loader">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||||
|
>
|
||||||
|
<Cpu size={20} />
|
||||||
|
</motion.div>
|
||||||
|
<span>AUTHENTICATING...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>SECURE LOGIN</span>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
/* MFA Form */
|
||||||
|
<form onSubmit={handleMfaVerify} className="hosp-form">
|
||||||
|
<motion.div
|
||||||
|
className="hosp-input-group"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<label className="hosp-label">TOTP Security Code</label>
|
||||||
|
<div className="hosp-input-wrap">
|
||||||
|
<KeyRound className="hosp-input-icon" size={18} />
|
||||||
|
<input
|
||||||
|
id="hospital-mfa-code"
|
||||||
|
type="text"
|
||||||
|
className="hosp-input mono"
|
||||||
|
placeholder="000 000"
|
||||||
|
maxLength={6}
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="hosp-input-focus-line" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<p className="hosp-mfa-hint">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
Enter the 6-digit code from your authenticator app
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
className="hosp-submit-btn"
|
||||||
|
disabled={isLoading || loginSuccess}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
{loginSuccess ? (
|
||||||
|
<motion.div
|
||||||
|
className="hosp-success-check"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={22} />
|
||||||
|
<span>VERIFIED</span>
|
||||||
|
</motion.div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="hosp-loader">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||||
|
>
|
||||||
|
<Cpu size={20} />
|
||||||
|
</motion.div>
|
||||||
|
<span>VERIFYING...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<span>VERIFY IDENTITY</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hosp-back-btn"
|
||||||
|
onClick={() => { setLoginStep('login'); setShowError(''); setMfaCode(''); }}
|
||||||
|
>
|
||||||
|
← Back to Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showError && (
|
||||||
|
<motion.div
|
||||||
|
className="hosp-error-badge"
|
||||||
|
initial={{ opacity: 0, height: 0, marginTop: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto', marginTop: 16 }}
|
||||||
|
exit={{ opacity: 0, height: 0, marginTop: 0 }}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={14} />
|
||||||
|
<span>{showError}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Security badge */}
|
||||||
|
{!showError && (
|
||||||
|
<motion.div
|
||||||
|
className="hosp-security-badge"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={14} />
|
||||||
|
<span>HIPAA-COMPLIANT SECURE CONNECTION</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="hosp-footer">
|
||||||
|
<NavLink to="/launcher" className="hosp-portal-link">
|
||||||
|
<Monitor size={14} />
|
||||||
|
VIEW ALL PORTALS
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Status indicators */}
|
||||||
|
<div className="hosp-status-bar">
|
||||||
|
<div className="hosp-status-item">
|
||||||
|
<Wifi size={12} />
|
||||||
|
<span>UPLINK ACTIVE</span>
|
||||||
|
</div>
|
||||||
|
<div className="hosp-status-dot" />
|
||||||
|
<div className="hosp-status-item">
|
||||||
|
<Activity size={12} />
|
||||||
|
<span>NODE ONLINE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HospitalLogin;
|
||||||
@@ -113,7 +113,7 @@ export const HospitalsNetwork: React.FC = () => {
|
|||||||
setRealHospitals(hospitalNodes);
|
setRealHospitals(hospitalNodes);
|
||||||
|
|
||||||
// Derive current issues
|
// Derive current issues
|
||||||
const newIssues = hospitalNodes.map(h => {
|
const newIssues = hospitalNodes.map((h: any) => {
|
||||||
const [available] = (h.beds || '0/0').split('/').map((n: string) => parseInt(n) || 0);
|
const [available] = (h.beds || '0/0').split('/').map((n: string) => parseInt(n) || 0);
|
||||||
if (available === 0) return { type: 'CRITICAL', msg: `${h.name}: Zero bed capacity`, hospital: h.name };
|
if (available === 0) return { type: 'CRITICAL', msg: `${h.name}: Zero bed capacity`, hospital: h.name };
|
||||||
if (available < 5) return { type: 'WARNING', msg: `${h.name}: Low bed availability`, hospital: h.name };
|
if (available < 5) return { type: 'WARNING', msg: `${h.name}: Low bed availability`, hospital: h.name };
|
||||||
@@ -582,7 +582,7 @@ export const HospitalsNetwork: React.FC = () => {
|
|||||||
onSubmit={triggerSubmit}
|
onSubmit={triggerSubmit}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
<HospitalRegistrationForm onSubmit={handleHospitalSubmit} loading={isSubmitting} />
|
<HospitalRegistrationForm onSubmit={handleHospitalSubmit} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* EDIT HOSPITAL MODAL */}
|
{/* EDIT HOSPITAL MODAL */}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
// Patient Modal states
|
// Patient Modal states
|
||||||
const [isAddPatientsModalOpen, setIsAddPatientsModalOpen] = useState(false);
|
const [isAddPatientsModalOpen, setIsAddPatientsModalOpen] = useState(false);
|
||||||
const [bulkPatients, setBulkPatients] = useState<any[]>([
|
const [bulkPatients, setBulkPatients] = useState<any[]>([
|
||||||
{ gender: 'Male', triage_code: 'RED', symptoms: [] }
|
{ gender: 'Male', triage_level: 'RED', symptoms: [] }
|
||||||
]);
|
]);
|
||||||
const [isMapPickerOpen, setIsMapPickerOpen] = useState(false);
|
const [isMapPickerOpen, setIsMapPickerOpen] = useState(false);
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
age: 0,
|
age: 0,
|
||||||
gender: 'Male',
|
gender: 'Male',
|
||||||
symptoms: [],
|
symptoms: [],
|
||||||
triage_code: 'GREEN'
|
triage_level: 'GREEN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
caller_id: JSON.parse(localStorage.getItem('teleems_user') || '{}').id || '',
|
caller_id: JSON.parse(localStorage.getItem('teleems_user') || '{}').id || '',
|
||||||
@@ -272,7 +272,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
notes: formData.notes,
|
notes: formData.notes,
|
||||||
patients: formData.patients?.map(p => ({
|
patients: formData.patients?.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
triage_code: p.triage_code || 'GREEN'
|
triage_level: p.triage_level || 'GREEN'
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
const response = await incidentsApi.createIncident(payload, token);
|
const response = await incidentsApi.createIncident(payload, token);
|
||||||
@@ -317,7 +317,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
age: 0,
|
age: 0,
|
||||||
gender: 'Male',
|
gender: 'Male',
|
||||||
symptoms: [],
|
symptoms: [],
|
||||||
triage_code: 'GREEN'
|
triage_level: 'GREEN'
|
||||||
}],
|
}],
|
||||||
caller_id: JSON.parse(localStorage.getItem('teleems_user') || '{}').id || '',
|
caller_id: JSON.parse(localStorage.getItem('teleems_user') || '{}').id || '',
|
||||||
organisationId: null
|
organisationId: null
|
||||||
@@ -386,7 +386,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
if (response.status === 200 || response.status === 201) {
|
if (response.status === 200 || response.status === 201) {
|
||||||
setIsAddPatientsModalOpen(false);
|
setIsAddPatientsModalOpen(false);
|
||||||
fetchIncidentDetails(selectedIncident.id);
|
fetchIncidentDetails(selectedIncident.id);
|
||||||
setBulkPatients([{ gender: 'Male', triage_code: 'RED', symptoms: [] }]);
|
setBulkPatients([{ gender: 'Male', triage_level: 'RED', symptoms: [] }]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Bulk add patients failed:', error);
|
console.error('Bulk add patients failed:', error);
|
||||||
@@ -767,23 +767,23 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{selectedIncident.patients.map((p, idx) => (
|
{selectedIncident.patients.map((p, idx) => (
|
||||||
<div key={idx} className="glass" style={{ padding: '16px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--card-border)', borderRadius: '12px', position: 'relative', overflow: 'hidden' }}>
|
<div key={idx} className="glass" style={{ padding: '16px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--card-border)', borderRadius: '12px', position: 'relative', overflow: 'hidden' }}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: '4px', background: (p.triage_level || p.triage_code) === 'RED' ? 'var(--alert-red)' : (p.triage_level || p.triage_code) === 'YELLOW' ? 'var(--warning-amber)' : 'var(--accent-green)' }}></div>
|
<div style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: '4px', background: (p.triage_level || p.triage_level) === 'RED' ? 'var(--alert-red)' : (p.triage_level || p.triage_level) === 'YELLOW' ? 'var(--warning-amber)' : 'var(--accent-green)' }}></div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name || 'ANONYMOUS-SUBJECT'}</div>
|
<div style={{ fontWeight: 800, fontSize: '0.9rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name || 'ANONYMOUS-SUBJECT'}</div>
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.age || '?'}Y · {p.gender} · TRIAGE: {(p.triage_level || p.triage_code)?.toUpperCase()}</div>
|
<div style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', marginTop: '2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.age || '?'}Y · {p.gender} · TRIAGE: {(p.triage_level || p.triage_level)?.toUpperCase()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
fontSize: '0.6rem',
|
fontSize: '0.6rem',
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
background: (p.triage_level || p.triage_code) === 'RED' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(0,0,0,0.02)',
|
background: (p.triage_level || p.triage_level) === 'RED' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(0,0,0,0.02)',
|
||||||
color: (p.triage_level || p.triage_code) === 'RED' ? 'var(--alert-red)' : 'var(--text-primary)',
|
color: (p.triage_level || p.triage_level) === 'RED' ? 'var(--alert-red)' : 'var(--text-primary)',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: (p.triage_level || p.triage_code) === 'RED' ? 'rgba(239, 68, 68, 0.2)' : 'var(--card-border)'
|
borderColor: (p.triage_level || p.triage_level) === 'RED' ? 'rgba(239, 68, 68, 0.2)' : 'var(--card-border)'
|
||||||
}}>
|
}}>
|
||||||
CODE {(p.triage_level || p.triage_code) || 'PENDING'}
|
CODE {(p.triage_level || p.triage_level) || 'PENDING'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
@@ -1098,7 +1098,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newPatients = [...(formData.patients || [])];
|
const newPatients = [...(formData.patients || [])];
|
||||||
newPatients.push({ name: '', age: 0, gender: 'Male', symptoms: [], triage_code: 'GREEN' });
|
newPatients.push({ name: '', age: 0, gender: 'Male', symptoms: [], triage_level: 'GREEN' });
|
||||||
setFormData({ ...formData, patients: newPatients });
|
setFormData({ ...formData, patients: newPatients });
|
||||||
}}
|
}}
|
||||||
style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)', color: 'var(--accent-cyan)', padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 800, cursor: 'pointer' }}
|
style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid var(--accent-cyan)', color: 'var(--accent-cyan)', padding: '4px 10px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 800, cursor: 'pointer' }}
|
||||||
@@ -1168,10 +1168,10 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '6px', fontWeight: 700 }}>TRIAGE</label>
|
<label style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '6px', fontWeight: 700 }}>TRIAGE</label>
|
||||||
<select
|
<select
|
||||||
value={patient.triage_code}
|
value={patient.triage_level}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newPatients = [...(formData.patients || [])];
|
const newPatients = [...(formData.patients || [])];
|
||||||
newPatients[index] = { ...newPatients[index], triage_code: e.target.value };
|
newPatients[index] = { ...newPatients[index], triage_level: e.target.value };
|
||||||
setFormData({ ...formData, patients: newPatients });
|
setFormData({ ...formData, patients: newPatients });
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||||
@@ -1190,7 +1190,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
value={patient.symptoms?.map((s: any) => typeof s === 'string' ? s : s?.name || '').join(', ')}
|
value={patient.symptoms?.map((s: any) => typeof s === 'string' ? s : s?.name || '').join(', ')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newPatients = [...(formData.patients || [])];
|
const newPatients = [...(formData.patients || [])];
|
||||||
newPatients[index] = { ...newPatients[index], symptoms: e.target.value.split(',').map(s => s.trim()).filter(s => s !== '') };
|
newPatients[index] = { ...newPatients[index], symptoms: e.target.value.split(',').map(s => s.trim()).filter(s => s !== '').map(s => ({ name: s, duration_minutes: 0 })) };
|
||||||
setFormData({ ...formData, patients: newPatients });
|
setFormData({ ...formData, patients: newPatients });
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||||
@@ -1320,10 +1320,10 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '6px' }}>TRIAGE CODE</label>
|
<label style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '6px' }}>TRIAGE CODE</label>
|
||||||
<select
|
<select
|
||||||
value={patient.triage_code}
|
value={patient.triage_level}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newPatients = [...bulkPatients];
|
const newPatients = [...bulkPatients];
|
||||||
newPatients[index].triage_code = e.target.value;
|
newPatients[index].triage_level = e.target.value;
|
||||||
setBulkPatients(newPatients);
|
setBulkPatients(newPatients);
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--card-border)', borderRadius: '8px', color: 'var(--text-primary)', fontSize: '0.8rem' }}
|
||||||
@@ -1354,7 +1354,7 @@ export const LiveIncidents: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setBulkPatients([...bulkPatients, { gender: 'Male', triage_code: 'GREEN', symptoms: [] }])}
|
onClick={() => setBulkPatients([...bulkPatients, { gender: 'Male', triage_level: 'GREEN', symptoms: [] }])}
|
||||||
style={{ padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px dashed var(--card-border)', borderRadius: '12px', color: 'var(--text-secondary)', fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer' }}
|
style={{ padding: '12px', background: 'rgba(0,0,0,0.02)', border: '1px dashed var(--card-border)', borderRadius: '12px', color: 'var(--text-secondary)', fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
+ ADD ANOTHER SUBJECT
|
+ ADD ANOTHER SUBJECT
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
.launcher-page {
|
.launcher-page {
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-y: auto;
|
background: var(--hull-dark-l);
|
||||||
overflow-x: hidden;
|
background-color: hsl(var(--hull-dark-h), var(--hull-dark-s), 4%);
|
||||||
background: #020617;
|
color: #fff;
|
||||||
color: #f8fafc;
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -27,74 +26,37 @@
|
|||||||
background: rgba(59, 130, 246, 0.4);
|
background: rgba(59, 130, 246, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Background Effects */
|
/* Background Effects - Clean Clinical Finish */
|
||||||
.launcher-bg {
|
.launcher-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: hsl(var(--hull-dark-h), var(--hull-dark-s), 2%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-grid {
|
.launcher-grid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
linear-gradient(to right, hsla(var(--accent-cyan-h), 100%, 50%, 0.015) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
linear-gradient(to bottom, hsla(var(--accent-cyan-h), 100%, 50%, 0.015) 1px, transparent 1px);
|
||||||
background-size: 50px 50px;
|
background-size: 80px 80px;
|
||||||
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
mask-image: radial-gradient(circle at 0% 0%, black, transparent 100%);
|
||||||
}
|
|
||||||
|
|
||||||
.launcher-blob {
|
|
||||||
position: absolute;
|
|
||||||
width: 500px;
|
|
||||||
height: 500px;
|
|
||||||
filter: blur(100px);
|
|
||||||
opacity: 0.15;
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-1 {
|
|
||||||
top: -100px;
|
|
||||||
left: -100px;
|
|
||||||
background: #3b82f6;
|
|
||||||
animation: float 20s infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-2 {
|
|
||||||
bottom: -100px;
|
|
||||||
right: -100px;
|
|
||||||
background: #8b5cf6;
|
|
||||||
animation: float 25s infinite alternate-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-3 {
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: #10b981;
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
opacity: 0.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float {
|
|
||||||
from { transform: translate(0, 0); }
|
|
||||||
to { transform: translate(100px, 50px); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.launcher-header {
|
.launcher-header {
|
||||||
position: relative;
|
position: sticky;
|
||||||
z-index: 10;
|
top: 0;
|
||||||
padding: 24px 40px;
|
z-index: 100;
|
||||||
|
padding: 20px 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid hsla(0, 0%, 100%, 0.05);
|
||||||
background: rgba(2, 6, 23, 0.5);
|
background: hsla(220, 30%, 5%, 0.8);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-brand {
|
.launcher-brand {
|
||||||
@@ -227,17 +189,22 @@
|
|||||||
/* Cards */
|
/* Cards */
|
||||||
.portal-card {
|
.portal-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(15, 23, 42, 0.6);
|
background: hsla(220, 30%, 10%, 0.4);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid hsla(0, 0%, 100%, 0.05);
|
||||||
border-radius: 24px;
|
border-radius: 28px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color 0.3s;
|
transition: var(--transition-snappy);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-card:hover {
|
.portal-card:hover {
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
|
background: hsla(220, 30%, 15%, 0.6);
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 30px 60px -12px hsla(0, 0%, 0%, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-card-glow {
|
.portal-card-glow {
|
||||||
@@ -246,14 +213,14 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: radial-gradient(circle at top right, var(--accent-color), transparent 70%);
|
background: radial-gradient(circle at top right, var(--accent-color), transparent 60%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.4s;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-card:hover .portal-card-glow {
|
.portal-card:hover .portal-card-glow {
|
||||||
opacity: 0.1;
|
opacity: 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-card-inner {
|
.portal-card-inner {
|
||||||
@@ -285,18 +252,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.portal-subtitle {
|
.portal-subtitle {
|
||||||
font-size: 0.75rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 800;
|
font-weight: 950;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.15em;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
|
filter: brightness(1.2);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-title {
|
.portal-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 800;
|
font-weight: 900;
|
||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-description {
|
.portal-description {
|
||||||
@@ -399,21 +369,20 @@
|
|||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.launcher-main-title {
|
.launcher-main-title { font-size: 3rem; }
|
||||||
font-size: 2.5rem;
|
.portal-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
}
|
}
|
||||||
.portal-grid {
|
|
||||||
grid-template-columns: 1fr;
|
@media (max-width: 768px) {
|
||||||
}
|
.launcher-header { padding: 16px 20px; }
|
||||||
.launcher-header {
|
.launcher-actions { display: none; }
|
||||||
padding: 16px 20px;
|
.launcher-main-title { font-size: 2.2rem; }
|
||||||
}
|
.launcher-intro { margin-bottom: 40px; }
|
||||||
.launcher-content {
|
.portal-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||||
padding: 40px 20px;
|
.launcher-content { padding: 40px 20px; }
|
||||||
}
|
.portal-card { padding: 24px; }
|
||||||
.footer-info {
|
.footer-info { gap: 20px; flex-direction: column; align-items: center; text-align: center; }
|
||||||
gap: 20px;
|
.footer-links { justify-content: center; width: 100%; margin-top: 24px; }
|
||||||
flex-wrap: wrap;
|
.launcher-footer { flex-direction: column; gap: 24px; padding: 40px 20px; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,76 +61,28 @@ const PortalCard: React.FC<PortalCardProps> = ({ title, subtitle, icon: Icon, co
|
|||||||
export const PerspectiveLauncher: React.FC = () => {
|
export const PerspectiveLauncher: React.FC = () => {
|
||||||
const portals = [
|
const portals = [
|
||||||
{
|
{
|
||||||
title: 'Admin Control',
|
title: 'Hospital Admin',
|
||||||
subtitle: 'SYSTEM ADMINISTRATION',
|
subtitle: 'FACILITY MANAGEMENT',
|
||||||
icon: Shield,
|
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',
|
color: '#3b82f6',
|
||||||
path: '/login/hospital-group',
|
path: '/login/hospital',
|
||||||
description: 'Centralized oversight for multiple healthcare facilities and resource allocation.'
|
description: 'Full administrative control over facility configuration, department setup, and staff management.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Hospital',
|
title: 'ED Doctor (ERCP)',
|
||||||
subtitle: 'FACILITY OPERATIONS',
|
subtitle: 'CLINICAL OPERATIONS',
|
||||||
icon: Building,
|
icon: Stethoscope,
|
||||||
color: '#10b981',
|
color: '#10b981',
|
||||||
path: '/login/hospital',
|
path: '/login/hospital',
|
||||||
description: 'End-to-end management of emergency department operations and bed tracking.'
|
description: 'Manage active patient triage, teleconsultations, and real-time vital monitoring from the mission control.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Provider',
|
title: 'Hospital Coordinator',
|
||||||
subtitle: 'CLINICAL CARE',
|
subtitle: 'PATIENT LOGISTICS',
|
||||||
icon: Stethoscope,
|
icon: Building,
|
||||||
color: '#8b5cf6',
|
color: '#8b5cf6',
|
||||||
path: '/login/provider',
|
path: '/login/hospital',
|
||||||
description: 'Dedicated interface for healthcare professionals to manage patient care.'
|
description: 'Coordinate with incoming ambulances, manage bed capacity, and oversee patient documentation handovers.'
|
||||||
},
|
|
||||||
{
|
|
||||||
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.'
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import { Lock,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -60,7 +60,7 @@ export const FleetInventory: React.FC = () => {
|
|||||||
</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 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={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
<div style={{ color: '#F59E0B' }}><Clock size={20} /></div>
|
<div style={{ color: '#F59E0B' }}><Lock size={20} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#F59E0B' }}>8</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 style={{ fontSize: '0.7rem', opacity: 0.5, textTransform: 'uppercase' }}>EXPIRING (30D)</div>
|
||||||
|
|||||||
469
src/pages/hospital/AdmissionsBoard.tsx
Normal file
469
src/pages/hospital/AdmissionsBoard.tsx
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
UserCheck,
|
||||||
|
Clock,
|
||||||
|
Building2,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
LogOut,
|
||||||
|
RefreshCw,
|
||||||
|
X,
|
||||||
|
ChevronRight,
|
||||||
|
Activity,
|
||||||
|
BedDouble,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { hospitalApi } from '../../api/hospital';
|
||||||
|
|
||||||
|
interface AdmissionsBoardProps {
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdmissionsBoard: React.FC<AdmissionsBoardProps> = ({ onRefresh }) => {
|
||||||
|
const [admissions, setAdmissions] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||||
|
const [dischargingId, setDischargingId] = useState<string | null>(null);
|
||||||
|
const [confirmDischargeId, setConfirmDischargeId] = useState<string | null>(null);
|
||||||
|
const [apiStats, setApiStats] = useState({ total: 0, admitted: 0, discharged: 0 });
|
||||||
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
|
|
||||||
|
const loadAdmissions = async (filter = statusFilter) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
if (!token) return;
|
||||||
|
const res = await hospitalApi.getAdmissions(token, 1, 50, filter);
|
||||||
|
console.log('[Admissions] API Response:', res);
|
||||||
|
|
||||||
|
let items: any[] = [];
|
||||||
|
const data = res?.data || res;
|
||||||
|
|
||||||
|
if (data?.items && Array.isArray(data.items)) {
|
||||||
|
items = data.items;
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
items = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdmissions(items);
|
||||||
|
|
||||||
|
if (data?.stats) {
|
||||||
|
setApiStats(data.stats);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch admissions:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAdmissions();
|
||||||
|
const interval = setInterval(() => loadAdmissions(), 20000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
const handleDischarge = async (admissionId: string) => {
|
||||||
|
setDischargingId(admissionId);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const res = await hospitalApi.dischargePatient(admissionId, token);
|
||||||
|
console.log('[Admissions] Discharge Response:', res);
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
alert('Discharge failed: ' + (res.error.message || 'Unknown error'));
|
||||||
|
} else {
|
||||||
|
setSuccessMsg('Patient discharged successfully');
|
||||||
|
setTimeout(() => setSuccessMsg(''), 3000);
|
||||||
|
setConfirmDischargeId(null);
|
||||||
|
loadAdmissions();
|
||||||
|
if (onRefresh) onRefresh();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert('Error discharging patient: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
setDischargingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: apiStats.total,
|
||||||
|
admitted: apiStats.admitted,
|
||||||
|
discharged: apiStats.discharged,
|
||||||
|
}), [apiStats]);
|
||||||
|
|
||||||
|
const filteredAdmissions = useMemo(() => {
|
||||||
|
return admissions.filter(admission => {
|
||||||
|
const patientName = admission.patient?.name || '';
|
||||||
|
const deptName = admission.department?.name || '';
|
||||||
|
const matchesSearch =
|
||||||
|
patientName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
deptName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
admission.patient_id?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
// The API already filters by status if not 'ALL',
|
||||||
|
// but we keep this for consistency and safety.
|
||||||
|
const matchesStatus = statusFilter === 'ALL' || admission.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
}, [admissions, searchQuery, statusFilter]);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '--';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }) +
|
||||||
|
' ' + d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeSince = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '--';
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const mins = Math.floor((diff % 3600000) / 60000);
|
||||||
|
if (hours > 24) return Math.floor(hours / 24) + 'd ' + (hours % 24) + 'h';
|
||||||
|
if (hours > 0) return hours + 'h ' + mins + 'm';
|
||||||
|
return mins + 'm';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statuses = ['ALL', 'ADMITTED', 'DISCHARGED'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="module-content"
|
||||||
|
style={{ background: 'var(--tactical-bg)', minHeight: '100%', padding: '24px' }}
|
||||||
|
>
|
||||||
|
{/* Success Toast */}
|
||||||
|
{successMsg && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 30, left: '50%', transform: 'translateX(-50%)', zIndex: 9999,
|
||||||
|
background: '#10b981', color: '#fff', padding: '12px 24px', borderRadius: '12px',
|
||||||
|
fontWeight: 800, fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
boxShadow: '0 8px 24px rgba(16, 185, 129, 0.3)',
|
||||||
|
}}>
|
||||||
|
<CheckCircle2 size={18} /> {successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metrics Row */}
|
||||||
|
<div className="metrics-row-modern">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Records', count: stats.total, icon: <Activity size={20} />, color: '#0ea5e9' },
|
||||||
|
{ label: 'Currently Admitted', count: stats.admitted, icon: <BedDouble size={20} />, color: '#8b5cf6' },
|
||||||
|
{ label: 'Discharged', count: stats.discharged, icon: <CheckCircle2 size={20} />, color: '#10b981' },
|
||||||
|
].map((m, i) => (
|
||||||
|
<div key={i} className="metric-card-premium">
|
||||||
|
<div className="metric-icon-wrap" style={{ color: m.color }}>
|
||||||
|
{m.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: '#0f172a', lineHeight: 1 }}>{m.count}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '4px' }}>{m.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header & Filters */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', marginTop: '8px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#8b5cf6', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.05em' }}>
|
||||||
|
<UserCheck size={14} /> ADMISSIONS REGISTRY
|
||||||
|
</div>
|
||||||
|
<h2 style={{ margin: '8px 0 0 0', fontSize: '1.5rem', fontWeight: 700, color: '#1e293b', letterSpacing: '-0.02em' }}>Ward Management</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<div className="search-mini" style={{ width: '280px', background: '#f1f5f9', border: '1px solid #e2e8f0' }}>
|
||||||
|
<Search size={16} color="#64748b" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search patient, department..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setup-nav" style={{ background: '#f1f5f9', padding: '4px', borderRadius: '12px' }}>
|
||||||
|
{statuses.map(status => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
className={`setup-nav-item ${statusFilter === status ? 'active' : ''}`}
|
||||||
|
onClick={() => setStatusFilter(status)}
|
||||||
|
style={{ fontSize: '0.7rem', fontWeight: 700, padding: '6px 14px' }}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={loadAdmissions}
|
||||||
|
style={{
|
||||||
|
width: '40px', height: '40px', borderRadius: '12px', border: '1px solid #e2e8f0',
|
||||||
|
background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} color="#64748b" className={isLoading ? 'spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isLoading && admissions.length === 0 ? (
|
||||||
|
<div style={{ padding: '80px 40px', textAlign: 'center' }}>
|
||||||
|
<div className="spin" style={{ width: 32, height: 32, border: '3px solid #e2e8f0', borderTop: '3px solid #8b5cf6', borderRadius: '50%', margin: '0 auto 16px auto' }} />
|
||||||
|
<p style={{ color: '#64748b', fontWeight: 600 }}>Loading admissions...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredAdmissions.length === 0 ? (
|
||||||
|
<div style={{ padding: '80px 40px', textAlign: 'center', background: '#f8fafc', borderRadius: '32px', border: '2px dashed #e2e8f0' }}>
|
||||||
|
<div style={{ width: 80, height: 80, borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px auto', boxShadow: '0 10px 25px rgba(0,0,0,0.05)' }}>
|
||||||
|
<BedDouble size={32} color="#94a3b8" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ color: '#1e293b', margin: '0 0 8px 0', fontWeight: 800 }}>No Admissions Found</h3>
|
||||||
|
<p style={{ color: '#64748b', fontSize: '0.9rem', maxWidth: '300px', margin: '8px auto 0 auto' }}>
|
||||||
|
{searchQuery || statusFilter !== 'ALL' ? 'No records match your current filters.' : 'No patients have been admitted yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredAdmissions.map((admission) => (
|
||||||
|
<motion.div
|
||||||
|
key={admission.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status indicator bar */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 0, top: 0, bottom: 0, width: '4px',
|
||||||
|
background: admission.status === 'ADMITTED' ? '#8b5cf6' : '#10b981',
|
||||||
|
borderRadius: '20px 0 0 20px',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '24px 24px 24px 28px',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr auto',
|
||||||
|
gap: '24px',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
{/* Patient Info */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Patient</div>
|
||||||
|
<div style={{ fontSize: '1.05rem', fontWeight: 800, color: '#1e293b' }}>
|
||||||
|
{admission.patient?.name || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '6px', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{
|
||||||
|
background: admission.patient?.triage_code === 'RED' ? 'hsla(0, 84%, 60%, 0.1)' : 'hsla(38, 92%, 50%, 0.1)',
|
||||||
|
color: admission.patient?.triage_code === 'RED' ? '#ef4444' : '#f59e0b',
|
||||||
|
padding: '2px 8px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 800,
|
||||||
|
}}>
|
||||||
|
{admission.patient?.triage_code || '--'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#64748b', fontSize: '0.75rem', fontWeight: 600 }}>
|
||||||
|
{admission.patient?.age || '--'}Y • {admission.patient?.gender || '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{admission.patient?.chief_complaint && (
|
||||||
|
<div style={{ marginTop: '6px', fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
|
||||||
|
{admission.patient.chief_complaint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Department Info */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Department</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Building2 size={16} color="#8b5cf6" />
|
||||||
|
<span style={{ fontSize: '0.95rem', fontWeight: 700, color: '#1e293b' }}>
|
||||||
|
{admission.department?.name || '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600, marginTop: '6px' }}>
|
||||||
|
HOD: {admission.department?.headOfDepartment || '--'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '6px' }}>
|
||||||
|
<span style={{ fontSize: '0.7rem', color: '#64748b' }}>
|
||||||
|
Beds: <strong style={{ color: '#1e293b' }}>{admission.department?.occupiedBeds || 0}/{admission.department?.totalBedsCapacity || 0}</strong>
|
||||||
|
</span>
|
||||||
|
{admission.bed_type && (
|
||||||
|
<span style={{ fontSize: '0.7rem', background: '#f1f5f9', padding: '1px 6px', borderRadius: '4px', color: '#475569', fontWeight: 700 }}>
|
||||||
|
{admission.bed_type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Timeline</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
|
||||||
|
<Clock size={14} color="#8b5cf6" />
|
||||||
|
<span style={{ fontSize: '0.8rem', fontWeight: 700, color: '#1e293b' }}>
|
||||||
|
Admitted {formatDate(admission.admitted_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
|
||||||
|
Duration: <strong style={{ color: '#8b5cf6' }}>{getTimeSince(admission.admitted_at)}</strong>
|
||||||
|
</div>
|
||||||
|
{admission.discharged_at && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px' }}>
|
||||||
|
<CheckCircle2 size={14} color="#10b981" />
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#10b981' }}>
|
||||||
|
Discharged {formatDate(admission.discharged_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'flex-end' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 12px', borderRadius: '8px', fontSize: '0.7rem', fontWeight: 800,
|
||||||
|
background: admission.status === 'ADMITTED' ? 'hsla(262, 83%, 58%, 0.1)' : 'hsla(152, 69%, 40%, 0.1)',
|
||||||
|
color: admission.status === 'ADMITTED' ? '#8b5cf6' : '#10b981',
|
||||||
|
}}>
|
||||||
|
{admission.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{admission.status === 'ADMITTED' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDischargeId(admission.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
|
padding: '8px 16px', borderRadius: '10px',
|
||||||
|
border: '1px solid #e2e8f0', background: '#fff',
|
||||||
|
color: '#ef4444', fontWeight: 700, fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#fef2f2'; e.currentTarget.style.borderColor = '#fecaca'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#fff'; e.currentTarget.style.borderColor = '#e2e8f0'; }}
|
||||||
|
>
|
||||||
|
<LogOut size={14} /> Discharge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discharge Confirmation Modal */}
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{confirmDischargeId && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 10001, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setConfirmDischargeId(null)}
|
||||||
|
style={{ position: 'absolute', inset: 0, background: 'rgba(15, 23, 42, 0.4)', backdropFilter: 'blur(2px)' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
style={{
|
||||||
|
position: 'relative', background: '#fff', borderRadius: '20px',
|
||||||
|
width: '100%', maxWidth: '420px', overflow: 'hidden',
|
||||||
|
boxShadow: '0 20px 40px rgba(0,0,0,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '24px', borderBottom: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fef2f2' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: '12px', background: '#fee2e2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<AlertCircle size={20} color="#ef4444" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800, color: '#1e293b' }}>Confirm Discharge</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDischargeId(null)}
|
||||||
|
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#94a3b8' }}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
{(() => {
|
||||||
|
const adm = admissions.find(a => a.id === confirmDischargeId);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: '#64748b', fontSize: '0.9rem', lineHeight: 1.5, margin: '0 0 20px 0' }}>
|
||||||
|
You are about to discharge the following patient. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div style={{ background: '#f8fafc', borderRadius: '12px', padding: '16px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<div style={{ fontSize: '1rem', fontWeight: 800, color: '#1e293b' }}>{adm?.patient?.name || 'Unknown'}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748b', marginTop: '4px' }}>
|
||||||
|
{adm?.department?.name || '--'} • Admitted {formatDate(adm?.admitted_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px 24px', borderTop: '1px solid #f1f5f9', display: 'flex', justifyContent: 'flex-end', gap: '12px', background: '#f8fafc' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDischargeId(null)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px', borderRadius: '10px', border: '1px solid #e2e8f0',
|
||||||
|
background: '#fff', fontWeight: 700, fontSize: '0.85rem', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDischarge(confirmDischargeId!)}
|
||||||
|
disabled={!!dischargingId}
|
||||||
|
style={{
|
||||||
|
padding: '10px 24px', borderRadius: '10px', border: 'none',
|
||||||
|
background: '#ef4444', color: '#fff', fontWeight: 700, fontSize: '0.85rem',
|
||||||
|
cursor: dischargingId ? 'not-allowed' : 'pointer', opacity: dischargingId ? 0.7 : 1,
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dischargingId ? (
|
||||||
|
<>
|
||||||
|
<div className="spin" style={{ width: 14, height: 14, border: '2px solid rgba(255,255,255,0.3)', borderTop: '2px solid #fff', borderRadius: '50%' }} />
|
||||||
|
Discharging...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogOut size={14} /> Confirm Discharge
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
161
src/pages/hospital/DeptModal.tsx
Normal file
161
src/pages/hospital/DeptModal.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { X, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DeptModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
deptFormData: {
|
||||||
|
name: string;
|
||||||
|
headOfDepartment: string;
|
||||||
|
totalBedsCapacity: number;
|
||||||
|
contactPhone: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
setDeptFormData: (data: any) => void;
|
||||||
|
allUsers: any[];
|
||||||
|
selectedHospital: any;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeptModal: React.FC<DeptModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
deptFormData,
|
||||||
|
setDeptFormData,
|
||||||
|
allUsers,
|
||||||
|
selectedHospital,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="premium-modal-overlay">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
|
className="premium-modal-container"
|
||||||
|
>
|
||||||
|
<div className="modal-header-premium">
|
||||||
|
<h3>Add Department</h3>
|
||||||
|
<button className="modal-close-btn" onClick={onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setup-form-modern">
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Department Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={deptFormData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeptFormData({ ...deptFormData, name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g. Emergency & Trauma"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Head of Department</label>
|
||||||
|
<div
|
||||||
|
className="custom-select-wrapper"
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="setup-input-premium custom-select"
|
||||||
|
value={deptFormData.headOfDepartment}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeptFormData({
|
||||||
|
...deptFormData,
|
||||||
|
headOfDepartment: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ width: '100%', appearance: 'none' }}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select Head of Department
|
||||||
|
</option>
|
||||||
|
{allUsers
|
||||||
|
.filter(
|
||||||
|
(u) =>
|
||||||
|
u.hospitalId ===
|
||||||
|
(selectedHospital?.rawUser?.hospitalId ||
|
||||||
|
selectedHospital?.id) ||
|
||||||
|
u.organisationId ===
|
||||||
|
selectedHospital?.rawUser?.organisationId
|
||||||
|
)
|
||||||
|
.map((u, i) => (
|
||||||
|
<option key={i} value={u.name || u.username}>
|
||||||
|
{u.name || u.username} ({u.roles[0]?.replace('_', ' ')})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '12px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="form-group-premium"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label>Capacity (Beds)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="setup-input-premium"
|
||||||
|
style={{ width: '100%', marginTop: '10px' }}
|
||||||
|
value={deptFormData.totalBedsCapacity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeptFormData({
|
||||||
|
...deptFormData,
|
||||||
|
totalBedsCapacity: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Contact Phone</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
style={{ width: '100%', marginTop: '10px' }}
|
||||||
|
value={deptFormData.contactPhone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeptFormData({
|
||||||
|
...deptFormData,
|
||||||
|
contactPhone: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="+91-..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer-premium">
|
||||||
|
<button className="btn-secondary-glass" onClick={onClose}>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary-glass" onClick={onSubmit}>
|
||||||
|
ADD DEPARTMENT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
633
src/pages/hospital/EDMonitor.tsx
Normal file
633
src/pages/hospital/EDMonitor.tsx
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Activity,
|
||||||
|
Wind,
|
||||||
|
Video,
|
||||||
|
Zap,
|
||||||
|
ShieldAlert,
|
||||||
|
Database,
|
||||||
|
Radio,
|
||||||
|
Clock,
|
||||||
|
ChevronRight,
|
||||||
|
TrendingUp,
|
||||||
|
Filter,
|
||||||
|
AlertCircle,
|
||||||
|
Truck,
|
||||||
|
CheckCircle2,
|
||||||
|
X,
|
||||||
|
LayoutDashboard
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { hospitalApi } from '../../api/hospital';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
AreaChart,
|
||||||
|
Area
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
interface EDMonitorProps {
|
||||||
|
incomingPatients: any[];
|
||||||
|
selectedHospital: any;
|
||||||
|
departments: any[];
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onOpenBooking: () => void;
|
||||||
|
onOpenTelelink: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EDMonitor: React.FC<EDMonitorProps> = ({
|
||||||
|
incomingPatients,
|
||||||
|
selectedHospital,
|
||||||
|
departments,
|
||||||
|
onRefresh,
|
||||||
|
onOpenBooking,
|
||||||
|
onOpenTelelink,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [assigningPatientId, setAssigningPatientId] = useState<string | null>(null);
|
||||||
|
const [isAdmitting, setIsAdmitting] = useState(false);
|
||||||
|
|
||||||
|
const toggleExpand = (id: string) => {
|
||||||
|
setExpandedId(expandedId === id ? null : id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdmit = async (patientId: string, departmentId: string) => {
|
||||||
|
setIsAdmitting(true);
|
||||||
|
try {
|
||||||
|
const patient = incomingPatients.find(p => p.id === patientId);
|
||||||
|
const realId = patient?.originalId || patientId;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('teleems_token') || '';
|
||||||
|
const res = await hospitalApi.admitPatient(realId, departmentId, token);
|
||||||
|
if (res.status === 200 || res.status === 201) {
|
||||||
|
setAssigningPatientId(null);
|
||||||
|
if (onRefresh) onRefresh();
|
||||||
|
} else {
|
||||||
|
alert('Admission failed: ' + (res.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert('Error admitting patient: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
setIsAdmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
return {
|
||||||
|
total: incomingPatients.filter(p => p.status === 'IN_TRANSIT' || p.status === 'PATIENT_LOADED').length,
|
||||||
|
critical: incomingPatients.filter(p => (p.triage === 'RED' || p.triage === 'CRITICAL') && p.status !== 'HANDOFF_COMPLETE').length,
|
||||||
|
onsite: incomingPatients.filter(p => p.status === 'ARRIVED').length,
|
||||||
|
handoff: incomingPatients.filter(p => p.status === 'HANDOFF_COMPLETE').length,
|
||||||
|
};
|
||||||
|
}, [incomingPatients]);
|
||||||
|
|
||||||
|
const filteredPatients = useMemo(() => {
|
||||||
|
return incomingPatients.filter(patient => {
|
||||||
|
const matchesSearch =
|
||||||
|
patient.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
patient.mrn.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
patient.ambulanceId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
patient.complaint.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const matchesStatus = statusFilter === 'ALL' || patient.status === statusFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
}, [incomingPatients, searchQuery, statusFilter]);
|
||||||
|
|
||||||
|
const statuses = ['ALL', 'PATIENT_LOADED', 'IN_TRANSIT', 'ARRIVED', 'HANDOFF_COMPLETE'];
|
||||||
|
|
||||||
|
const triageData = useMemo(() => {
|
||||||
|
const counts: any = { RED: 0, ORANGE: 0, YELLOW: 0, GREEN: 0, WHITE: 0 };
|
||||||
|
incomingPatients.forEach(p => {
|
||||||
|
const t = String(p.triage).toUpperCase();
|
||||||
|
if (counts[t] !== undefined) counts[t]++;
|
||||||
|
else counts.GREEN++; // fallback
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
{ name: 'Critical', value: counts.RED, color: '#ef4444' },
|
||||||
|
{ name: 'Urgent', value: counts.ORANGE, color: '#f97316' },
|
||||||
|
{ name: 'Stable', value: counts.YELLOW, color: '#eab308' },
|
||||||
|
{ name: 'Minimal', value: counts.GREEN, color: '#22c55e' },
|
||||||
|
{ name: 'Routine', value: counts.WHITE, color: '#94a3b8' },
|
||||||
|
].filter(d => d.value > 0);
|
||||||
|
}, [incomingPatients]);
|
||||||
|
|
||||||
|
const loadData = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ time: '08:00', load: Math.max(0, stats.total - 2) },
|
||||||
|
{ time: '10:00', load: Math.max(0, stats.total - 1) },
|
||||||
|
{ time: '12:00', load: stats.total + 1 },
|
||||||
|
{ time: 'NOW', load: stats.total },
|
||||||
|
];
|
||||||
|
}, [stats.total]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="module-content ed-monitor-tactical"
|
||||||
|
style={{ background: 'var(--tactical-bg)', minHeight: '100%', padding: '24px' }}
|
||||||
|
>
|
||||||
|
{/* Metrics Row */}
|
||||||
|
<div className="metrics-row-modern">
|
||||||
|
{[
|
||||||
|
{ label: 'Incoming', count: stats.total, icon: <Activity size={20} />, color: '#0ea5e9' },
|
||||||
|
{ label: 'Critical', count: stats.critical, icon: <AlertCircle size={20} />, color: '#ef4444' },
|
||||||
|
{ label: 'On-Site', count: stats.onsite, icon: <Truck size={20} />, color: '#f59e0b' },
|
||||||
|
{ label: 'Completed', count: stats.handoff, icon: <CheckCircle2 size={20} />, color: '#10b981' },
|
||||||
|
].map((m, i) => (
|
||||||
|
<div key={i} className="metric-card-premium">
|
||||||
|
<div className="metric-icon-wrap" style={{ color: m.color }}>
|
||||||
|
{m.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: '#0f172a', lineHeight: 1 }}>{m.count}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '4px' }}>{m.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tactical-board-container">
|
||||||
|
<div className="board-header-tactical" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '32px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#ef4444', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.05em' }}>
|
||||||
|
<Radio size={14} /> LIVE TRIAGE DATA STREAM
|
||||||
|
</div>
|
||||||
|
<h2 style={{ margin: '8px 0 0 0', fontSize: '1.5rem', fontWeight: 700, color: '#1e293b', letterSpacing: '-0.02em' }}>Emergent Intake Board</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<div className="search-mini" style={{ width: '300px', background: '#f1f5f9', border: '1px solid #e2e8f0' }}>
|
||||||
|
<Search size={16} color="#64748b" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by Name, MRN, Unit..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setup-nav" style={{ background: '#f1f5f9', padding: '4px', borderRadius: '12px' }}>
|
||||||
|
{statuses.map(status => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
className={`setup-nav-item ${statusFilter === status ? 'active' : ''}`}
|
||||||
|
onClick={() => setStatusFilter(status)}
|
||||||
|
style={{ fontSize: '0.7rem', fontWeight: 700, padding: '6px 14px' }}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ed-grid-modern">
|
||||||
|
<div className="triage-feed-container">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredPatients.length > 0 ? (
|
||||||
|
filteredPatients.map((patient) => (
|
||||||
|
<motion.div
|
||||||
|
key={patient.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="tactical-patient-card"
|
||||||
|
>
|
||||||
|
<div className={`card-status-indicator ${patient.triage}`} />
|
||||||
|
|
||||||
|
<div className="card-main-tactical">
|
||||||
|
{/* Unit & ETA */}
|
||||||
|
<div className="unit-info-hex">
|
||||||
|
<div className="unit-label-premium">UNIT: {patient.ambulanceId}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<div style={{ width: 10, height: 10, background: patient.triage === 'RED' ? '#ef4444' : '#22c55e', borderRadius: '50%' }} />
|
||||||
|
<span style={{ fontSize: '0.9rem', fontWeight: 700, color: '#1e293b' }}>ETA {patient.eta}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.02em' }}>{patient.status.replace('_', ' ')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identity & Complaint */}
|
||||||
|
<div className="p-identity-block">
|
||||||
|
<div className="p-name-premium">{patient.name}</div>
|
||||||
|
<div className="p-meta-inline">
|
||||||
|
<span style={{ color: 'var(--accent-cyan)', background: 'rgba(14, 165, 233, 0.08)', padding: '2px 8px', borderRadius: '6px', fontSize: '0.7rem' }}>{patient.mrn}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{patient.age}Y</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{patient.gender}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<div className={`complaint-badge-tactical ${patient.triage === 'RED' ? 'CRITICAL' : ''}`}>
|
||||||
|
<TrendingUp size={14} />
|
||||||
|
{patient.complaint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vitals Monitor */}
|
||||||
|
<div className="vitals-monitor-block">
|
||||||
|
<div className="v-node-tactical hr">
|
||||||
|
<span className="v-val">{patient.vitals.hr}</span>
|
||||||
|
<span className="v-lbl">HR</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '1px', height: '30px', background: 'rgba(255,255,255,0.08)' }} />
|
||||||
|
<div className="v-node-tactical spo2">
|
||||||
|
<span className="v-val">{patient.vitals.spo2}</span>
|
||||||
|
<span className="v-lbl">SPO2</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '1px', height: '30px', background: 'rgba(255,255,255,0.08)' }} />
|
||||||
|
<div className="v-node-tactical bp">
|
||||||
|
<span className="v-val">{patient.vitals.bp}</span>
|
||||||
|
<span className="v-lbl">BP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="tactical-actions">
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className="action-btn-tactical primary" onClick={onOpenTelelink} style={{ flex: 1 }}>
|
||||||
|
<Video size={14} /> JOIN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn-tactical"
|
||||||
|
onClick={() => toggleExpand(patient.id)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
VIEW DATA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<button className="action-btn-tactical" onClick={() => setAssigningPatientId(patient.id)} style={{ flex: 1, background: '#f8fafc', color: 'var(--accent-cyan)', border: '1px solid var(--accent-cyan-soft)' }}>
|
||||||
|
<LayoutDashboard size={14} /> ASSIGN
|
||||||
|
</button>
|
||||||
|
{/* <button className="action-btn-tactical" onClick={onOpenBooking} style={{ flex: 1 }}>
|
||||||
|
TRIAGE REG.
|
||||||
|
</button> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '100px 40px', textAlign: 'center', background: '#f8fafc', borderRadius: '32px', border: '2px dashed #e2e8f0' }}>
|
||||||
|
<div style={{ width: 80, height: 80, borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px auto', boxShadow: '0 10px 25px rgba(0,0,0,0.05)' }}>
|
||||||
|
<Filter size={32} color="#94a3b8" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ color: '#1e293b', margin: '0 0 8px 0', fontWeight: 800 }}>Board Stabilized</h3>
|
||||||
|
<p style={{ color: '#64748b', fontSize: '0.9rem', maxWidth: '300px', margin: '8px auto 0 auto' }}>All dispatches accounted for. No incoming units match current filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ed-intel-sidebar" style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div className="hub-card premium-ops" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: '24px', padding: '24px' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginBottom: '20px' }}>Triage Distribution</div>
|
||||||
|
<div style={{ height: '180px', width: '100%' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={triageData}
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={75}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{triageData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
||||||
|
itemStyle={{ fontSize: '12px', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '10px', flexWrap: 'wrap' }}>
|
||||||
|
{triageData.map((d, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: d.color }} />
|
||||||
|
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#64748b' }}>{d.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hub-card premium-ops" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: '24px', padding: '24px' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginBottom: '20px' }}>Inflow Velocity</div>
|
||||||
|
<div style={{ height: '120px', width: '100%' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={loadData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorLoad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="#0ea5e9" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: '10px', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}
|
||||||
|
labelStyle={{ fontSize: '10px', color: '#94a3b8' }}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="load" stroke="#0ea5e9" fillOpacity={1} fill="url(#colorLoad)" strokeWidth={3} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hub-card premium-ops" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: '24px', padding: '24px' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', fontWeight: 900, color: '#ef4444', letterSpacing: '0.15em', textTransform: 'uppercase', marginBottom: '20px' }}>System Alerts</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<button className="code-btn-modern red" style={{ height: '60px' }}>
|
||||||
|
<div className="c-info">
|
||||||
|
<strong style={{ fontSize: '1rem' }}>CODE RED</strong>
|
||||||
|
<span>STAT Response Required</span>
|
||||||
|
</div>
|
||||||
|
<Zap size={20} fill="#fff" />
|
||||||
|
</button>
|
||||||
|
<button className="code-btn-modern trauma" style={{ height: '60px', background: 'linear-gradient(135deg, #f59e0b, #d97706)' }}>
|
||||||
|
<div className="c-info">
|
||||||
|
<strong style={{ fontSize: '1rem' }}>TRAUMA STAT</strong>
|
||||||
|
<span>Activation Sequence</span>
|
||||||
|
</div>
|
||||||
|
<ShieldAlert size={20} fill="#fff" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedId && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setExpandedId(null)}
|
||||||
|
style={{ position: 'absolute', inset: 0, background: 'rgba(15, 23, 42, 0.8)', backdropFilter: 'blur(4px)' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '24px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '1000px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const patient = incomingPatients.find(p => p.id === expandedId);
|
||||||
|
if (!patient) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div style={{ padding: '24px', borderBottom: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||||
|
<div className={`card-status-indicator ${patient.triage}`} style={{ width: '4px', height: '40px', borderRadius: '4px' }} />
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: '#1e293b' }}>{patient.name} <span style={{ color: '#94a3b8', fontWeight: 500, fontSize: '1rem' }}>• {patient.mrn}</span></h3>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#64748b', marginTop: '4px' }}>
|
||||||
|
{patient.age}Y • {patient.gender} • Unit {patient.ambulanceId} • Status: {patient.status.replace('_', ' ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(null)}
|
||||||
|
style={{ width: '40px', height: '40px', borderRadius: '12px', border: '1px solid #e2e8f0', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<X size={20} color="#64748b" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div style={{ padding: '32px', overflowY: 'auto' }}>
|
||||||
|
<div className="detail-module-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '32px' }}>
|
||||||
|
{/* Clinical Module */}
|
||||||
|
<div className="detail-module">
|
||||||
|
<h5 style={{ fontSize: '0.7rem', color: '#94a3b8', textTransform: 'uppercase', marginBottom: '16px', letterSpacing: '0.1em', fontWeight: 800 }}>Clinical Assessment</h5>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div style={{ background: '#f8fafc', padding: '16px', borderRadius: '12px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#64748b', marginBottom: '6px' }}>GCS TOTAL</div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#1e293b' }}>{patient.gcs?.total || '--'}</div>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#94a3b8', marginTop: '4px' }}>E:{patient.gcs?.eye} V:{patient.gcs?.verbal} M:{patient.gcs?.motor}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#f8fafc', padding: '16px', borderRadius: '12px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#64748b', marginBottom: '6px' }}>AVPU</div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#1e293b' }}>{patient.avpu}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#f8fafc', padding: '16px', borderRadius: '12px', border: '1px solid #e2e8f0', gridColumn: 'span 2' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#64748b', marginBottom: '8px' }}>PUPILS</div>
|
||||||
|
<div style={{ display: 'flex', gap: '30px' }}>
|
||||||
|
<div style={{ fontSize: '1rem', fontWeight: 700 }}><span style={{ color: '#94a3b8' }}>LEFT:</span> {patient.pupils?.left || '--'}</div>
|
||||||
|
<div style={{ fontSize: '1rem', fontWeight: 700 }}><span style={{ color: '#94a3b8' }}>RIGHT:</span> {patient.pupils?.right || '--'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HPI Module */}
|
||||||
|
<div className="detail-module">
|
||||||
|
<h5 style={{ fontSize: '0.7rem', color: '#94a3b8', textTransform: 'uppercase', marginBottom: '16px', letterSpacing: '0.1em', fontWeight: 800 }}>History of Illness (HPI)</h5>
|
||||||
|
<div style={{ background: '#fff', padding: '20px', borderRadius: '16px', border: '1px solid #e2e8f0', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)' }}>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>CHIEF COMPLAINT</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: '#1e293b' }}>{patient.complaint}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>ONSET</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{patient.hpi?.onset || '--'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>DURATION</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{patient.hpi?.duration || '--'}m</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>SEVERITY</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{patient.hpi?.severity || '--'}/10</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#94a3b8' }}>CHARACTER</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700 }}>{patient.hpi?.character || '--'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Medical Background */}
|
||||||
|
<div className="detail-module">
|
||||||
|
<h5 style={{ fontSize: '0.7rem', color: '#94a3b8', textTransform: 'uppercase', marginBottom: '16px', letterSpacing: '0.1em', fontWeight: 800 }}>Medical Records</h5>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'ALLERGIES', val: patient.allergies.map((a: any) => a.name).join(', '), color: '#fee2e2', textColor: '#b91c1c' },
|
||||||
|
{ label: 'MEDICATIONS', val: patient.medications.join(', '), color: '#f1f5f9', textColor: '#475569' },
|
||||||
|
{ label: 'CONDITIONS', val: patient.conditions.join(', '), color: '#f1f5f9', textColor: '#475569' },
|
||||||
|
{ label: 'SURGERIES', val: patient.surgeries.join(', '), color: '#f1f5f9', textColor: '#475569' },
|
||||||
|
].map((item, idx) => (
|
||||||
|
<div key={idx} style={{ background: item.val ? item.color : 'transparent', padding: '12px 16px', borderRadius: '10px', border: item.val ? 'none' : '1px dashed #e2e8f0' }}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#64748b', fontWeight: 800, marginBottom: '4px' }}>{item.label}</div>
|
||||||
|
<div style={{ fontSize: '0.9rem', color: item.textColor, fontWeight: 700 }}>{item.val || '--'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Info Row */}
|
||||||
|
<div style={{ gridColumn: '1 / -1', borderTop: '1px solid #f1f5f9', paddingTop: '32px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', gap: '24px' }}>
|
||||||
|
<div style={{ background: '#f8fafc', padding: '20px', borderRadius: '16px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94a3b8', marginBottom: '12px', fontWeight: 800 }}>INFORMER CONTACT</div>
|
||||||
|
<div style={{ display: 'flex', gap: '40px' }}>
|
||||||
|
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>NAME:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.informer?.name}</div></div>
|
||||||
|
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>RELATION:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.informer?.relation}</div></div>
|
||||||
|
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>PHONE:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.informer?.phone}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: patient.mlc?.is_mlc ? '#fffef2' : '#f8fafc', padding: '20px', borderRadius: '16px', border: `1px solid ${patient.mlc?.is_mlc ? '#fef08a' : '#e2e8f0'}` }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontWeight: 800 }}>MLC RECORD</div>
|
||||||
|
{patient.mlc?.is_mlc && <span style={{ background: '#fef9c3', color: '#854d0e', fontSize: '0.6rem', fontWeight: 900, padding: '4px 8px', borderRadius: '6px' }}>LEGAL CLEARANCE REQ.</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '40px' }}>
|
||||||
|
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>FIR:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.mlc?.fir || '--'}</div></div>
|
||||||
|
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>STATION:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.mlc?.station || '--'}</div></div>
|
||||||
|
<div><span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>OFFICER:</span> <div style={{ fontSize: '0.9rem', fontWeight: 700 }}>{patient.mlc?.officer || '--'}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Actions */}
|
||||||
|
<div style={{ padding: '24px', borderTop: '1px solid #f1f5f9', background: '#f8fafc', display: 'flex', justifyContent: 'flex-end', gap: '16px' }}>
|
||||||
|
<button className="action-btn-tactical" onClick={() => setExpandedId(null)} style={{ padding: '0 24px', height: '44px' }}>CLOSE REPORT</button>
|
||||||
|
<button className="action-btn-tactical primary" onClick={onOpenTelelink} style={{ padding: '0 32px', height: '44px' }}><Video size={18} /> INITIATE CALL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assignment Modal */}
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{assigningPatientId && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 10001, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setAssigningPatientId(null)}
|
||||||
|
style={{ position: 'absolute', inset: 0, background: 'rgba(15, 23, 42, 0.4)', backdropFilter: 'blur(2px)' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '20px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '450px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 20px 40px rgba(0,0,0,0.2)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '20px 24px', borderBottom: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800, color: '#1e293b' }}>Assign Department</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setAssigningPatientId(null)}
|
||||||
|
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#94a3b8' }}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '24px', maxHeight: '400px', overflowY: 'auto' }}>
|
||||||
|
<div style={{ marginBottom: '16px', fontSize: '0.85rem', color: '#64748b' }}>
|
||||||
|
Select target clinical unit for: <strong>{incomingPatients.find(p => p.id === assigningPatientId)?.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{departments.length > 0 ? departments.map(dept => (
|
||||||
|
<button
|
||||||
|
key={dept.id}
|
||||||
|
disabled={isAdmitting}
|
||||||
|
onClick={() => handleAdmit(assigningPatientId, dept.id)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px 18px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
background: '#fff',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: isAdmitting ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent-cyan)'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.borderColor = '#e2e8f0'}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: dept.isActive ? '#10b981' : '#cbd5e1' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#1e293b' }}>{dept.name}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>Available: {dept.availableBeds} Units</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} color="#94a3b8" />
|
||||||
|
</button>
|
||||||
|
)) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px', color: '#94a3b8', fontSize: '0.9rem' }}>
|
||||||
|
No active departments found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmitting && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'rgba(255,255,255,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10 }}>
|
||||||
|
<div className="spin" style={{ width: 24, height: 24, border: '2px solid #e2e8f0', borderTop: '2px solid var(--accent-cyan)', borderRadius: '50%' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
304
src/pages/hospital/EPCRRecords.tsx
Normal file
304
src/pages/hospital/EPCRRecords.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
Printer,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Activity,
|
||||||
|
CheckCircle2,
|
||||||
|
ShieldCheck,
|
||||||
|
ExternalLink,
|
||||||
|
ChevronRight,
|
||||||
|
TrendingUp,
|
||||||
|
Pill,
|
||||||
|
Syringe,
|
||||||
|
X,
|
||||||
|
Stethoscope
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface PCRRecord {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
ambulance: string;
|
||||||
|
patient: string;
|
||||||
|
age: string;
|
||||||
|
gender: string;
|
||||||
|
triage: 'RED' | 'YELLOW' | 'GREEN';
|
||||||
|
status: 'RECEIVED' | 'PENDING_SIGN';
|
||||||
|
emt: string;
|
||||||
|
hospitalSign: string | null;
|
||||||
|
vitals: { hr: string; spo2: string; bp: string; rr: string };
|
||||||
|
meds: string[];
|
||||||
|
interventions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PCR_DETAIL_VIEW = ({ record, onClose }: { record: PCRRecord; onClose: () => void }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="pcr-modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
|
className="pcr-detail-card"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="pcr-detail-header">
|
||||||
|
<div className="pcr-id-badge">
|
||||||
|
<FileText size={18} />
|
||||||
|
<span>RECORD {record.id}</span>
|
||||||
|
</div>
|
||||||
|
<button className="close-pcr-btn" onClick={onClose}><X size={20} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pcr-detail-content">
|
||||||
|
<section className="pcr-section patient-bio">
|
||||||
|
<div className="section-label">PATIENT INFORMATION</div>
|
||||||
|
<div className="bio-grid">
|
||||||
|
<div className="bio-item"><span className="label">Full Name:</span> <span className="value">{record.patient}</span></div>
|
||||||
|
<div className="bio-item"><span className="label">Age/Gender:</span> <span className="value">{record.age} / {record.gender}</span></div>
|
||||||
|
<div className="bio-item"><span className="label">Ambulance:</span> <span className="value">{record.ambulance} (ALS)</span></div>
|
||||||
|
<div className="bio-item"><span className="label">Lead EMT:</span> <span className="value">{record.emt}</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="pcr-row-split">
|
||||||
|
<section className="pcr-section vitals-trends">
|
||||||
|
<div className="section-label"><TrendingUp size={14} /> VITAL SIGN TRENDS</div>
|
||||||
|
<div className="vitals-trend-grid">
|
||||||
|
<div className="trend-item">
|
||||||
|
<span className="label">HR (BPM)</span>
|
||||||
|
<div className="sparkline-placeholder" />
|
||||||
|
<span className="value">{record.vitals.hr}</span>
|
||||||
|
</div>
|
||||||
|
<div className="trend-item">
|
||||||
|
<span className="label">SpO2 (%)</span>
|
||||||
|
<div className="sparkline-placeholder green" />
|
||||||
|
<span className="value">{record.vitals.spo2}</span>
|
||||||
|
</div>
|
||||||
|
<div className="trend-item">
|
||||||
|
<span className="label">BP (mmHg)</span>
|
||||||
|
<span className="value">{record.vitals.bp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="pcr-section interventions">
|
||||||
|
<div className="section-label"><Stethoscope size={14} /> INTERVENTIONS & MEDS</div>
|
||||||
|
<div className="med-list">
|
||||||
|
{record.meds.map((med, i) => (
|
||||||
|
<div key={i} className="med-item"><Pill size={12} /> {med}</div>
|
||||||
|
))}
|
||||||
|
{record.interventions.map((int, i) => (
|
||||||
|
<div key={i} className="med-item highlight"><Activity size={12} /> {int}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="pcr-section hms-integration">
|
||||||
|
<div className="section-label">TRANSFER OF CARE & SIGN-OFF</div>
|
||||||
|
<div className="sign-off-panel">
|
||||||
|
{record.status === 'PENDING_SIGN' ? (
|
||||||
|
<div className="pending-sign-area">
|
||||||
|
<p>I verify that the patient and the associated ePCR have been received by the clinical team.</p>
|
||||||
|
<button className="confirm-sign-btn">DIGITALLY SIGN & SYNC TO HMIS</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="completed-sign-area">
|
||||||
|
<ShieldCheck size={24} className="text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="signed-by">Signed by: {record.hospitalSign}</div>
|
||||||
|
<div className="signed-at">Timestamp: {record.date} (Synced)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pcr-detail-footer">
|
||||||
|
<button className="pcr-action-btn primary"><Download size={16} /> DOWNLOAD PDF</button>
|
||||||
|
<button className="pcr-action-btn"><Printer size={16} /> PRINT RECORD</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EPCRRecords: React.FC = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedPCR, setSelectedPCR] = useState<PCRRecord | null>(null);
|
||||||
|
|
||||||
|
const records: PCRRecord[] = [
|
||||||
|
{
|
||||||
|
id: 'PCR-8821',
|
||||||
|
date: '2026-05-05 12:30',
|
||||||
|
ambulance: 'KA-01-AM-102',
|
||||||
|
patient: 'Rajesh Khanna',
|
||||||
|
age: '45',
|
||||||
|
gender: 'M',
|
||||||
|
triage: 'RED',
|
||||||
|
status: 'RECEIVED',
|
||||||
|
emt: 'Arjun K.',
|
||||||
|
hospitalSign: 'Dr. Ramesh',
|
||||||
|
vitals: { hr: '124', spo2: '88', bp: '100/60', rr: '24' },
|
||||||
|
meds: ['Epinephrine 1mg', 'Amiodarone 300mg'],
|
||||||
|
interventions: ['CPR Performed', 'Intubation', 'IV Access Established']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'PCR-8815',
|
||||||
|
date: '2026-05-05 11:15',
|
||||||
|
ambulance: 'KA-05-MN-88',
|
||||||
|
patient: 'Unknown Male',
|
||||||
|
age: '28',
|
||||||
|
gender: 'M',
|
||||||
|
triage: 'RED',
|
||||||
|
status: 'PENDING_SIGN',
|
||||||
|
emt: 'Suman R.',
|
||||||
|
hospitalSign: null,
|
||||||
|
vitals: { hr: '142', spo2: '82', bp: '80/40', rr: '28' },
|
||||||
|
meds: ['Normal Saline 500ml', 'TXA 1g'],
|
||||||
|
interventions: ['C-Collar Applied', 'Hemorrhage Control', 'Oxygen (15L)']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'PCR-8798',
|
||||||
|
date: '2026-05-04 22:40',
|
||||||
|
ambulance: 'KA-03-KL-44',
|
||||||
|
patient: 'Amit Shah',
|
||||||
|
age: '58',
|
||||||
|
gender: 'M',
|
||||||
|
triage: 'GREEN',
|
||||||
|
status: 'RECEIVED',
|
||||||
|
emt: 'Kiran P.',
|
||||||
|
hospitalSign: 'Nurse Meera',
|
||||||
|
vitals: { hr: '82', spo2: '98', bp: '120/80', rr: '16' },
|
||||||
|
meds: [],
|
||||||
|
interventions: ['Splinting (Right Leg)']
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="module-content"
|
||||||
|
>
|
||||||
|
<div className="module-header-modern">
|
||||||
|
<div className="title-wrap">
|
||||||
|
<h3>ePCR RECEIPT & ARCHIVE</h3>
|
||||||
|
<div className="live-pill">
|
||||||
|
<span className="pulse" /> DIGITAL HANDOVER REPOSITORY
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-area-header">
|
||||||
|
<div className="search-mini">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by Patient, MRN, Incident ID..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="register-staff-btn-premium">
|
||||||
|
<Filter size={18} />
|
||||||
|
<span className="hide-mobile">FILTERS</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="epcr-stats-strip">
|
||||||
|
<div className="mini-stat">
|
||||||
|
<span className="label">TODAY'S ePCRs</span>
|
||||||
|
<span className="value">12</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-stat">
|
||||||
|
<span className="label">AWAITING SIGNATURE</span>
|
||||||
|
<span className="value-alert">01</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-stat">
|
||||||
|
<span className="label">SYNCED TO HMIS</span>
|
||||||
|
<span className="value">11</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="epcr-table-container">
|
||||||
|
<table className="staff-table-premium">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Record ID & Date</th>
|
||||||
|
<th>Ambulance & EMT</th>
|
||||||
|
<th>Patient Details</th>
|
||||||
|
<th>Triage & Clinical Status</th>
|
||||||
|
<th>Handoff Signature</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{records.map(record => (
|
||||||
|
<tr key={record.id} className="table-row-hover" onClick={() => setSelectedPCR(record)}>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div className="record-icon-mini"><FileText size={14} /></div>
|
||||||
|
<div>
|
||||||
|
<div className="record-id">{record.id}</div>
|
||||||
|
<div className="record-date">{record.date}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="record-meta">
|
||||||
|
<span className="reg-no">{record.ambulance}</span>
|
||||||
|
<span className="emt-name">{record.emt}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="record-patient">{record.patient}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="record-status-wrap">
|
||||||
|
<span className={`triage-tag triage-${record.triage.toLowerCase()}`}>{record.triage}</span>
|
||||||
|
<span className={`receive-status ${record.status.toLowerCase()}`}>
|
||||||
|
{record.status === 'RECEIVED' ? <CheckCircle2 size={12} /> : <Clock size={12} />}
|
||||||
|
{record.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{record.hospitalSign ? (
|
||||||
|
<div className="sign-badge">
|
||||||
|
<ShieldCheck size={12} />
|
||||||
|
{record.hospitalSign}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="sign-action-btn">PENDING SIGN</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="action-row-mini">
|
||||||
|
<button className="tag-btn" title="View ePCR"><ExternalLink size={14} /></button>
|
||||||
|
<button className="tag-btn" title="Download PDF"><Download size={14} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedPCR && <PCR_DETAIL_VIEW record={selectedPCR} onClose={() => setSelectedPCR(null)} />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
166
src/pages/hospital/FleetView.tsx
Normal file
166
src/pages/hospital/FleetView.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Truck,
|
||||||
|
MapPin,
|
||||||
|
Activity,
|
||||||
|
Droplet,
|
||||||
|
AlertTriangle,
|
||||||
|
Filter,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Navigation,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
ShieldCheck,
|
||||||
|
Search,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const FleetView: React.FC = () => {
|
||||||
|
const [filterType, setFilterType] = useState('ALL');
|
||||||
|
const [isPrivateOnly, setIsPrivateOnly] = useState(false);
|
||||||
|
|
||||||
|
const fleet = [
|
||||||
|
{ id: 'AMB-01', type: 'ALS', status: 'RUNNING', patient: 'TR-1082', fuel: '75%', crew: 'EMT Arjun, Driver Som', eta: '4m', coords: [12.9716, 77.5946], network: 'PUBLIC' },
|
||||||
|
{ id: 'AMB-02', type: 'BLS', status: 'IDLE', patient: null, fuel: '92%', crew: 'EMT Suman, Driver Ravi', eta: '-', coords: [12.935, 77.6245], network: 'PRIVATE' },
|
||||||
|
{ id: 'AMB-03', type: 'Transport', status: 'BREAKDOWN', patient: null, fuel: '15%', crew: 'None', eta: '-', coords: [12.95, 77.6], alert: 'Engine Overheat', network: 'PUBLIC' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="module-content"
|
||||||
|
>
|
||||||
|
<div className="module-header-modern">
|
||||||
|
<div className="title-wrap">
|
||||||
|
<h3>FLEET VISIBILITY DASHBOARD</h3>
|
||||||
|
<div className="live-pill">
|
||||||
|
<span className="pulse" /> {fleet.filter(f => f.status === 'RUNNING').length} ACTIVE UNITS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-area-header">
|
||||||
|
<div className="filter-group-premium">
|
||||||
|
<button className={`filter-chip ${!isPrivateOnly ? 'active' : ''}`} onClick={() => setIsPrivateOnly(false)}>NETWORK FLEET</button>
|
||||||
|
<button className={`filter-chip ${isPrivateOnly ? 'active' : ''}`} onClick={() => setIsPrivateOnly(true)}>PRIVATE FLEET</button>
|
||||||
|
</div>
|
||||||
|
<div className="search-mini">
|
||||||
|
<Search size={14} />
|
||||||
|
<input type="text" placeholder="Search Vehicle ID..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fleet-visibility-grid-premium">
|
||||||
|
<div className="map-command-surface">
|
||||||
|
<div className="map-overlay-vignette" />
|
||||||
|
<div className="map-scan-lines" />
|
||||||
|
|
||||||
|
<div className="map-placeholder-premium">
|
||||||
|
<div className="map-coordinate-grid" />
|
||||||
|
|
||||||
|
{fleet.map(unit => (
|
||||||
|
<motion.div
|
||||||
|
key={unit.id}
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
whileHover={{ scale: 1.2, zIndex: 100 }}
|
||||||
|
className={`node-marker-premium status-${unit.status.toLowerCase()}`}
|
||||||
|
style={{
|
||||||
|
left: `${(unit.coords[1] - 77.5) * 600}px`,
|
||||||
|
top: `${(13.0 - unit.coords[0]) * 600}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="marker-core">
|
||||||
|
<Truck size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="marker-ping" />
|
||||||
|
<div className="marker-label-premium">
|
||||||
|
<span className="id">{unit.id}</span>
|
||||||
|
<span className="type">{unit.type}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="map-hud-controls">
|
||||||
|
<div className="hud-group">
|
||||||
|
<button className="hud-btn"><Navigation size={18} /></button>
|
||||||
|
<div className="hud-divider" />
|
||||||
|
<button className="hud-btn"><MapPin size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="hud-group zoom">
|
||||||
|
<button className="hud-btn">+</button>
|
||||||
|
<button className="hud-btn">-</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fleet-mission-sidebar">
|
||||||
|
<div className="sidebar-head">
|
||||||
|
<div className="head-text">
|
||||||
|
<h4>ACTIVE ASSETS</h4>
|
||||||
|
<p>REGION: BENGALURU-NORTH-X2</p>
|
||||||
|
</div>
|
||||||
|
<div className="status-summary-mini">
|
||||||
|
<span className="dot active" />
|
||||||
|
<span className="val">{fleet.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fleet-unit-stack">
|
||||||
|
{fleet.map(unit => (
|
||||||
|
<motion.div
|
||||||
|
key={unit.id}
|
||||||
|
whileHover={{ x: 6 }}
|
||||||
|
className={`unit-mission-card ${unit.alert ? 'critical' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="unit-card-header">
|
||||||
|
<div className="type-badge">{unit.type} UNIT</div>
|
||||||
|
<div className={`status-pill ${unit.status.toLowerCase()}`}>{unit.status}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="unit-card-body">
|
||||||
|
<h3 className="unit-id-large">{unit.id}</h3>
|
||||||
|
<div className="unit-stats-row">
|
||||||
|
<div className="u-stat">
|
||||||
|
<Droplet size={12} />
|
||||||
|
<span>{unit.fuel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="u-stat">
|
||||||
|
<Clock size={12} />
|
||||||
|
<span className="highlight-cyan">{unit.eta || '--'} ETA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unit.patient && (
|
||||||
|
<div className="mission-active-indicator">
|
||||||
|
<div className="m-label">ACTIVE MISSION</div>
|
||||||
|
<div className="m-id">{unit.patient}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="unit-card-footer">
|
||||||
|
<div className="crew-mini">
|
||||||
|
<Users size={12} />
|
||||||
|
<span>{unit.crew}</span>
|
||||||
|
</div>
|
||||||
|
<button className="unit-action-btn"><Eye size={14} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unit.alert && (
|
||||||
|
<div className="unit-card-alert">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>{unit.alert.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
255
src/pages/hospital/HospitalAnalytics.tsx
Normal file
255
src/pages/hospital/HospitalAnalytics.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
BarChart,
|
||||||
|
PieChart,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
Download,
|
||||||
|
Filter,
|
||||||
|
Activity,
|
||||||
|
Users,
|
||||||
|
Video,
|
||||||
|
Zap,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Search,
|
||||||
|
FileBarChart,
|
||||||
|
BrainCircuit,
|
||||||
|
Database
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart as ReBarChart,
|
||||||
|
Bar,
|
||||||
|
Cell,
|
||||||
|
PieChart as RePieChart,
|
||||||
|
Pie,
|
||||||
|
LineChart,
|
||||||
|
Line
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
export const HospitalAnalytics: React.FC = () => {
|
||||||
|
const [timeRange, setTimeRange] = useState('7D');
|
||||||
|
|
||||||
|
const volumeData = [
|
||||||
|
{ name: '00:00', count: 5, baseline: 4 },
|
||||||
|
{ name: '04:00', count: 12, baseline: 8 },
|
||||||
|
{ name: '08:00', count: 28, baseline: 22 },
|
||||||
|
{ name: '12:00', count: 42, baseline: 35 },
|
||||||
|
{ name: '16:00', count: 38, baseline: 40 },
|
||||||
|
{ name: '20:00', count: 22, baseline: 18 },
|
||||||
|
{ name: '23:59', count: 8, baseline: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const triageData = [
|
||||||
|
{ name: 'Red (Critical)', value: 25, color: 'hsl(0, 84%, 60%)' },
|
||||||
|
{ name: 'Yellow (Emergent)', value: 45, color: 'hsl(38, 92%, 50%)' },
|
||||||
|
{ name: 'Green (Standard)', value: 30, color: 'hsl(142, 70%, 45%)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const departmentEfficiency = [
|
||||||
|
{ dept: 'ER', load: 85, cap: 90 },
|
||||||
|
{ dept: 'ICU', load: 92, cap: 80 },
|
||||||
|
{ dept: 'Cardio', load: 60, cap: 75 },
|
||||||
|
{ dept: 'Trauma', load: 78, cap: 85 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="module-content analytics-v2"
|
||||||
|
>
|
||||||
|
<div className="module-header-modern">
|
||||||
|
<div className="title-wrap">
|
||||||
|
<div className="clinical-intel-badge">
|
||||||
|
<BrainCircuit size={16} />
|
||||||
|
CONSOLIDATED CLINICAL INTELLIGENCE
|
||||||
|
</div>
|
||||||
|
<h3>Hospital Command Insights</h3>
|
||||||
|
</div>
|
||||||
|
<div className="action-area-header">
|
||||||
|
<div className="intel-filters">
|
||||||
|
{['24H', '7D', '30D', 'ALL'].map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
className={`intel-filter-btn ${timeRange === t ? 'active' : ''}`}
|
||||||
|
onClick={() => setTimeRange(t)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="register-staff-btn-premium">
|
||||||
|
<FileBarChart size={18} />
|
||||||
|
<span className="hide-mobile">EXTRACT REPORT</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="intelligence-grid">
|
||||||
|
{/* Row 1: High Level KPIs */}
|
||||||
|
<div className="kpi-strip">
|
||||||
|
<div className="kpi-card-modern neon-blue">
|
||||||
|
<div className="kpi-icon"><Activity size={20} /></div>
|
||||||
|
<div className="kpi-info">
|
||||||
|
<label>System Utilization</label>
|
||||||
|
<div className="kpi-value-wrap">
|
||||||
|
<span className="value">84.2%</span>
|
||||||
|
<span className="trend positive">+2.4%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi-card-modern neon-purple">
|
||||||
|
<div className="kpi-icon"><Video size={20} /></div>
|
||||||
|
<div className="kpi-info">
|
||||||
|
<label>TeleLink Saturation</label>
|
||||||
|
<div className="kpi-value-wrap">
|
||||||
|
<span className="value">62/h</span>
|
||||||
|
<span className="trend positive">+18%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi-card-modern neon-green">
|
||||||
|
<div className="kpi-icon"><Users size={20} /></div>
|
||||||
|
<div className="kpi-info">
|
||||||
|
<label>Total Intake (v24)</label>
|
||||||
|
<div className="kpi-value-wrap">
|
||||||
|
<span className="value">342</span>
|
||||||
|
<span className="trend neutral">STABLE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi-card-modern neon-amber">
|
||||||
|
<div className="kpi-icon"><Clock size={20} /></div>
|
||||||
|
<div className="kpi-info">
|
||||||
|
<label>P-ETA Variance</label>
|
||||||
|
<div className="kpi-value-wrap">
|
||||||
|
<span className="value">-1.2m</span>
|
||||||
|
<span className="trend positive">IMPROVED</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Main Traffic & Triage */}
|
||||||
|
<div className="analytics-body-layout">
|
||||||
|
<div className="intel-card large">
|
||||||
|
<div className="card-top">
|
||||||
|
<div className="card-title">
|
||||||
|
<TrendingUp size={16} />
|
||||||
|
<span>Patient Inflow / Capacity Overlap</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend-mini">
|
||||||
|
<span className="dot current" /> Live Intake
|
||||||
|
<span className="dot base" /> Expected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="viewport-chart">
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<AreaChart data={volumeData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorIntake" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--accent-cyan)" stopOpacity={0.1}/>
|
||||||
|
<stop offset="95%" stopColor="var(--accent-cyan)" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsla(215, 32%, 17%, 0.05)" />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 10, fontWeight: 750, fill: 'var(--text-muted)' }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fontWeight: 750, fill: 'var(--text-muted)' }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: 'var(--shadow-premium)', background: '#fff' }}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="count" stroke="var(--accent-cyan)" strokeWidth={3} fillOpacity={1} fill="url(#colorIntake)" />
|
||||||
|
<Line type="monotone" dataKey="baseline" stroke="var(--text-muted)" strokeDasharray="5 5" strokeWidth={1} dot={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="intel-card side">
|
||||||
|
<div className="card-top">
|
||||||
|
<div className="card-title">
|
||||||
|
<PieChart size={16} />
|
||||||
|
<span>Urgency Index</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="viewport-chart donut">
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<RePieChart>
|
||||||
|
<Pie
|
||||||
|
data={triageData}
|
||||||
|
innerRadius={65}
|
||||||
|
outerRadius={85}
|
||||||
|
paddingAngle={8}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{triageData.map((e, idx) => <Cell key={idx} fill={e.color} stroke="none" />)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</RePieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="donut-center-label">
|
||||||
|
<span className="v">126</span>
|
||||||
|
<span className="l">TOTAL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pie-legend-v2">
|
||||||
|
{triageData.map(t => (
|
||||||
|
<div key={t.name} className="legend-row">
|
||||||
|
<div className="l-side">
|
||||||
|
<div className="l-dot" style={{ background: t.color }} />
|
||||||
|
<span className="l-name">{t.name.split(' ')[0]}</span>
|
||||||
|
</div>
|
||||||
|
<span className="l-val">{t.value}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Dept Load & Alerts */}
|
||||||
|
<div className="intelligence-footer-grid">
|
||||||
|
<div className="intel-card medium no-bg">
|
||||||
|
<div className="card-title small">DEPARTMENTAL LOAD (HEATMAP)</div>
|
||||||
|
<div className="dept-load-stack">
|
||||||
|
{departmentEfficiency.map(d => (
|
||||||
|
<div key={d.dept} className="dept-progress-block">
|
||||||
|
<div className="d-info">
|
||||||
|
<span className="d-name">{d.dept}</span>
|
||||||
|
<span className="d-val">{d.load}% Load</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-bar-bg">
|
||||||
|
<div className="d-bar-fill" style={{ width: `${d.load}%`, background: d.load > 85 ? 'var(--alert-red)' : 'var(--accent-cyan)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="intel-card medium glass-alert">
|
||||||
|
<div className="card-title small alert-mode"><Activity size={14} /> LIVE ANOMALY DETECTION</div>
|
||||||
|
<div className="anomaly-list">
|
||||||
|
<div className="anomaly-item">
|
||||||
|
<span className="time">14:02</span>
|
||||||
|
<p>Suboptimal triage time detected in ER South Cluster.</p>
|
||||||
|
</div>
|
||||||
|
<div className="anomaly-item warning">
|
||||||
|
<span className="time">13:45</span>
|
||||||
|
<p>TeleLink latency spike (+45ms) reported in Hub 4.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
141
src/pages/hospital/HospitalSelector.tsx
Normal file
141
src/pages/hospital/HospitalSelector.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
ChevronRight,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface HospitalSelectorProps {
|
||||||
|
hospitals: any[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onSelect: (hospital: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HospitalSelector: React.FC<HospitalSelectorProps> = ({
|
||||||
|
hospitals,
|
||||||
|
isLoading,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="hospital-selection-overlay">
|
||||||
|
<div className="selection-background">
|
||||||
|
<div className="glow-sphere top-left" />
|
||||||
|
<div className="glow-sphere bottom-right" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="selection-content"
|
||||||
|
>
|
||||||
|
<div className="selection-header">
|
||||||
|
<div className="brand-logo large">
|
||||||
|
<Activity size={48} className="pulse-aura" />
|
||||||
|
CURESELECT{' '}
|
||||||
|
<span style={{ fontWeight: 400, color: 'var(--text-secondary)' }}>
|
||||||
|
EMS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1>Command Node Protocol</h1>
|
||||||
|
<p className="selection-subtitle">
|
||||||
|
Regional Emergency Service Network · Authorized Node Access Only
|
||||||
|
</p>
|
||||||
|
<div className="security-scan-line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hospital-grid">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading-hospitals">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1, ease: 'linear' }}
|
||||||
|
>
|
||||||
|
<Activity size={48} color="var(--accent-cyan)" />
|
||||||
|
</motion.div>
|
||||||
|
<p style={{ letterSpacing: '0.2em', fontWeight: 800 }}>
|
||||||
|
ESTABLISHING SECURE UPLINK...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : hospitals.length === 0 ? (
|
||||||
|
<div className="no-hospitals">
|
||||||
|
<AlertTriangle size={48} color="var(--alert-amber)" />
|
||||||
|
<p>
|
||||||
|
No active hospital nodes detected within the regional network grid.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="select-hospital-btn"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
RESCAN NETWORK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hospitals.map((h, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={h.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 30 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
delay: idx * 0.08,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: [0.16, 1, 0.3, 1]
|
||||||
|
}}
|
||||||
|
whileHover={{ y: -8, boxShadow: '0 20px 40px hsla(215, 32%, 17%, 0.12)' }}
|
||||||
|
className="hospital-select-card-premium"
|
||||||
|
onClick={() => onSelect(h)}
|
||||||
|
>
|
||||||
|
<div className="card-top-accent" />
|
||||||
|
<div className="h-icon-cluster">
|
||||||
|
<div className="h-icon-orb">
|
||||||
|
<Building2 size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="h-status-indicator pulse-slow" data-status={h.status.toLowerCase().replace(' ', '-')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-card-content">
|
||||||
|
<span className="h-type-tag">{h.type.toUpperCase()} NODE</span>
|
||||||
|
<h3 className="h-name-premium">{h.name}</h3>
|
||||||
|
|
||||||
|
<div className="h-meta-grid">
|
||||||
|
<div className="h-meta-item">
|
||||||
|
<Users size={14} />
|
||||||
|
<span>{h.admin || 'Dr. Admin'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-meta-item">
|
||||||
|
<Activity size={14} />
|
||||||
|
<span>{h.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="capacity-section">
|
||||||
|
<div className="capacity-header">
|
||||||
|
<span>LOAD CAPACITY</span>
|
||||||
|
<span className="capacity-val">{h.beds}</span>
|
||||||
|
</div>
|
||||||
|
<div className="capacity-track">
|
||||||
|
<motion.div
|
||||||
|
className="capacity-fill"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{
|
||||||
|
width: (parseInt(h.beds) / parseInt(h.beds.split('/')[1] || '100')) * 100 + '%'
|
||||||
|
}}
|
||||||
|
transition={{ delay: 0.5 + idx * 0.1, duration: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="h-access-btn">
|
||||||
|
INITIALIZE SESSION <ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
136
src/pages/hospital/PatientArchive.tsx
Normal file
136
src/pages/hospital/PatientArchive.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Database,
|
||||||
|
Search,
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
User,
|
||||||
|
Activity,
|
||||||
|
MapPin,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
CheckCircle2,
|
||||||
|
Video
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const PatientArchive: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'LIST' | 'TIMELINE'>('LIST');
|
||||||
|
|
||||||
|
const archive = [
|
||||||
|
{ id: 'INC-9012', date: '2026-05-04', name: 'Rajit Bose', age: 52, gender: 'M', triage: 'RED', category: 'CARDIAC', outcome: 'DISCHARGED', ambulance: 'ALS-02', teleLink: true },
|
||||||
|
{ id: 'INC-8955', date: '2026-05-03', name: 'Sana Khan', age: 24, gender: 'F', triage: 'YELLOW', category: 'TRAUMA', outcome: 'ADMITTED', ambulance: 'BLS-09', teleLink: false },
|
||||||
|
{ id: 'INC-8940', date: '2026-05-03', name: 'Unknown Male', age: 0, gender: 'M', triage: 'RED', category: 'STROKE', outcome: 'DECEASED', ambulance: 'ALS-01', teleLink: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="module-content"
|
||||||
|
>
|
||||||
|
<div className="module-header-modern">
|
||||||
|
<div className="title-wrap">
|
||||||
|
<h3>PATIENT & INCIDENT HISTORY</h3>
|
||||||
|
<div className="live-pill">
|
||||||
|
<span className="pulse" /> COMPREHENSIVE CARE RECORDS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-area-header">
|
||||||
|
<div className="date-picker-mini">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span>LAST 30 DAYS</span>
|
||||||
|
</div>
|
||||||
|
<div className="search-mini">
|
||||||
|
<Search size={14} />
|
||||||
|
<input type="text" placeholder="Search by ID, Name, MRN..." />
|
||||||
|
</div>
|
||||||
|
<button className="register-staff-btn-premium">
|
||||||
|
<Filter size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="archive-grid">
|
||||||
|
<div className="archive-list-panel">
|
||||||
|
<table className="staff-table-premium">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Patient ID & Date</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Provider Details</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
<th>History</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{archive.map(p => (
|
||||||
|
<tr key={p.id} className="archive-row-hover">
|
||||||
|
<td>
|
||||||
|
<div className="patient-main-ident">
|
||||||
|
<strong>{p.name}</strong>
|
||||||
|
<span>{p.id} · {p.date}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="cat-badge-wrap">
|
||||||
|
<span className={`cat-pill cat-${p.category.toLowerCase()}`}>{p.category}</span>
|
||||||
|
<span className={`triage-tag triage-${p.triage.toLowerCase()}`}>{p.triage}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="amb-emt-meta">
|
||||||
|
<span>{p.ambulance}</span>
|
||||||
|
{p.teleLink && <Video size={10} style={{ color: 'var(--accent-cyan)' }} />}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`outcome-pill outcome-${p.outcome.toLowerCase()}`}>{p.outcome}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="tag-btn"><ChevronRight size={14} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="archive-metrics-side">
|
||||||
|
<div className="side-metric-card">
|
||||||
|
<h4>Outcome Distribution</h4>
|
||||||
|
<div className="metric-bar-chart">
|
||||||
|
{/* Simple Mock Bars */}
|
||||||
|
<div className="bar-row">
|
||||||
|
<span>Admitted</span>
|
||||||
|
<div className="bar-container"><div className="bar-val" style={{ width: '65%', background: 'var(--accent-cyan)' }} /></div>
|
||||||
|
<span className="perc">65%</span>
|
||||||
|
</div>
|
||||||
|
<div className="bar-row">
|
||||||
|
<span>Discharged</span>
|
||||||
|
<div className="bar-container"><div className="bar-val" style={{ width: '25%', background: 'var(--accent-green)' }} /></div>
|
||||||
|
<span className="perc">25%</span>
|
||||||
|
</div>
|
||||||
|
<div className="bar-row">
|
||||||
|
<span>Referred</span>
|
||||||
|
<div className="bar-container"><div className="bar-val" style={{ width: '10%', background: 'var(--warning-amber)' }} /></div>
|
||||||
|
<span className="perc">10%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="side-metric-card">
|
||||||
|
<h4>Timeline Insight (Scene to ED)</h4>
|
||||||
|
<div className="avg-time-display">
|
||||||
|
<Clock size={24} style={{ color: 'var(--accent-cyan)' }} />
|
||||||
|
<div className="time-val">28.4 <small>min</small></div>
|
||||||
|
<span className="trend down">▼ 2m vs Last Month</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
121
src/pages/hospital/ReferralsSetup.tsx
Normal file
121
src/pages/hospital/ReferralsSetup.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Hospital,
|
||||||
|
MapPin,
|
||||||
|
PhoneCall,
|
||||||
|
Mail,
|
||||||
|
Award,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
User,
|
||||||
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
Building2,
|
||||||
|
Trash2,
|
||||||
|
Edit2
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const ReferralsSetup: React.FC = () => {
|
||||||
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
|
||||||
|
const referrals = [
|
||||||
|
{ id: 'REF-01', name: 'Narayana Health City', type: 'Tertiary Care', specialty: 'CARDIAC, ONCOLOGY', contact: 'Dr. Vikram - 9845011223', address: 'Hosur Road, Bangalore', status: 'ACTIVE' },
|
||||||
|
{ id: 'REF-02', name: 'St. Johns Medical College', type: 'Teaching Hospital', specialty: 'TRAUMA, PEDIATRICS', contact: 'Coordinator Ravi - 9845099881', address: 'Koramangala, Bangalore', status: 'ACTIVE' },
|
||||||
|
{ id: 'REF-03', name: 'Nimhans', type: 'Psychiatric/Neuro', specialty: 'NEUROLOGY, NEUROSURGERY', contact: 'ER Helpdesk - 080-26995000', address: 'Hosur Road, Bangalore', status: 'ACTIVE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="module-content"
|
||||||
|
>
|
||||||
|
<div className="module-header-modern">
|
||||||
|
<div className="title-wrap">
|
||||||
|
<h3>REFERRAL HOSPITAL NETWORK</h3>
|
||||||
|
<div className="live-pill">
|
||||||
|
<span className="pulse" /> {referrals.length} TRUSTED PARTNERS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-area-header">
|
||||||
|
<button className="register-staff-btn-premium" onClick={() => setIsAddOpen(true)}>
|
||||||
|
<Plus size={18} />
|
||||||
|
<span>ADD REFERRAL NODE</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="referral-grid-layout">
|
||||||
|
<div className="referral-list-main">
|
||||||
|
{referrals.map(ref => (
|
||||||
|
<div key={ref.id} className="referral-node-card">
|
||||||
|
<div className="node-icon-bubble">
|
||||||
|
<Building2 size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="node-info-content">
|
||||||
|
<div className="node-top-row">
|
||||||
|
<div className="node-title-stack">
|
||||||
|
<h4>{ref.name}</h4>
|
||||||
|
<span className="node-type">{ref.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="node-badge-active">PARTNERED</div>
|
||||||
|
</div>
|
||||||
|
<div className="node-details-grid">
|
||||||
|
<div className="d-item">
|
||||||
|
<Award size={14} />
|
||||||
|
<span>Specialties: {ref.specialty}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-item">
|
||||||
|
<MapPin size={14} />
|
||||||
|
<span>{ref.address}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-item">
|
||||||
|
<PhoneCall size={14} />
|
||||||
|
<span>{ref.contact}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="node-footer-actions">
|
||||||
|
<button className="node-action"><Edit2 size={14} /> Configure Notifications</button>
|
||||||
|
<button className="node-action-danger"><Trash2 size={14} /> Remove Node</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="referral-settings-side">
|
||||||
|
<div className="side-card-premium">
|
||||||
|
<div className="card-header-mini">
|
||||||
|
<Settings size={16} />
|
||||||
|
<h4>Global Referral Preferences</h4>
|
||||||
|
</div>
|
||||||
|
<div className="setting-control-row">
|
||||||
|
<div className="setting-info">
|
||||||
|
<strong>Auto-Pre-Alert</strong>
|
||||||
|
<span>Notify referral hub immediately on booking</span>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" defaultChecked />
|
||||||
|
</div>
|
||||||
|
<div className="setting-control-row">
|
||||||
|
<div className="setting-info">
|
||||||
|
<strong>Mortuary Referral</strong>
|
||||||
|
<span>Enable deceased-outcome routing logic</span>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" defaultChecked />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="side-card-premium dark-accent">
|
||||||
|
<ShieldCheck size={32} style={{ opacity: 0.5, marginBottom: '16px' }} />
|
||||||
|
<h4>Protocol Compliance</h4>
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.8, lineHeight: 1.5 }}>
|
||||||
|
All outgoing referrals are logged and linked to active ePCR data for legal handoff audit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
502
src/pages/hospital/SetupPanel.tsx
Normal file
502
src/pages/hospital/SetupPanel.tsx
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
LayoutDashboard,
|
||||||
|
Stethoscope,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Phone,
|
||||||
|
Settings,
|
||||||
|
MoreVertical,
|
||||||
|
Activity
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card } from '../../components/Common';
|
||||||
|
|
||||||
|
interface SetupPanelProps {
|
||||||
|
setupSubTab: string;
|
||||||
|
onSubTabChange: (tab: string) => void;
|
||||||
|
selectedHospital: any;
|
||||||
|
setSelectedHospital: (h: any) => void;
|
||||||
|
departments: any[];
|
||||||
|
setDepartments: (d: any[]) => void;
|
||||||
|
allUsers: any[];
|
||||||
|
onSaveProfile: () => void;
|
||||||
|
onOpenStaffModal: () => void;
|
||||||
|
onEditStaff: (user: any) => void;
|
||||||
|
onOpenDeptModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SetupPanel: React.FC<SetupPanelProps> = ({
|
||||||
|
setupSubTab,
|
||||||
|
onSubTabChange,
|
||||||
|
selectedHospital,
|
||||||
|
setSelectedHospital,
|
||||||
|
departments,
|
||||||
|
setDepartments,
|
||||||
|
allUsers,
|
||||||
|
onSaveProfile,
|
||||||
|
onOpenStaffModal,
|
||||||
|
onEditStaff,
|
||||||
|
onOpenDeptModal,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.99 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="module-content"
|
||||||
|
>
|
||||||
|
<div className="setup-layout">
|
||||||
|
<aside className="setup-nav">
|
||||||
|
<button
|
||||||
|
className={`setup-nav-item ${
|
||||||
|
setupSubTab === 'PROFILE' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSubTabChange('PROFILE')}
|
||||||
|
>
|
||||||
|
<Building2 size={14} /> <span>Profile</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`setup-nav-item ${
|
||||||
|
setupSubTab === 'USERS' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSubTabChange('USERS')}
|
||||||
|
>
|
||||||
|
<Users size={14} /> <span>Users</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`setup-nav-item ${
|
||||||
|
setupSubTab === 'DEPTS' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSubTabChange('DEPTS')}
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={14} /> <span>Departments</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`setup-nav-item ${
|
||||||
|
setupSubTab === 'BEDS' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSubTabChange('BEDS')}
|
||||||
|
>
|
||||||
|
<Stethoscope size={14} /> <span>Bed Management</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="setup-content-modern no-scrollbar">
|
||||||
|
{setupSubTab === 'PROFILE' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
<Card title="Hospital Profile & Configuration">
|
||||||
|
<div className="setup-form-modern" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||||
|
<div className="form-group-premium" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label>Institution Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={selectedHospital?.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedHospital({
|
||||||
|
...selectedHospital,
|
||||||
|
name: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label>Physical Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={selectedHospital?.address || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedHospital({
|
||||||
|
...selectedHospital,
|
||||||
|
address: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Emergency Phone</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={selectedHospital?.emergency_phone || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedHospital({
|
||||||
|
...selectedHospital,
|
||||||
|
emergency_phone: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Contact Phone</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={selectedHospital?.contact_phone || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedHospital({
|
||||||
|
...selectedHospital,
|
||||||
|
contact_phone: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group-premium" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label>Contact Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={selectedHospital?.contact_email || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedHospital({
|
||||||
|
...selectedHospital,
|
||||||
|
contact_email: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>GPS Latitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={selectedHospital?.gps_lat || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedHospital({
|
||||||
|
...selectedHospital,
|
||||||
|
gps_lat: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>GPS Longitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={selectedHospital?.gps_lon || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedHospital({
|
||||||
|
...selectedHospital,
|
||||||
|
gps_lon: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group-premium" style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 0' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="nabh_status"
|
||||||
|
checked={selectedHospital?.nabh_status || false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedHospital({
|
||||||
|
...selectedHospital,
|
||||||
|
nabh_status: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="nabh_status" style={{ margin: 0, cursor: 'pointer', fontSize: '1rem', fontWeight: 'bold' }}>NABH Accredited Institution</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ gridColumn: '1 / -1', marginTop: '16px' }}>
|
||||||
|
<button
|
||||||
|
className="setup-confirm-btn"
|
||||||
|
onClick={onSaveProfile}
|
||||||
|
>
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupSubTab === 'USERS' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
>
|
||||||
|
<Card title="Personnel Registry">
|
||||||
|
<div className="setup-actions-bar">
|
||||||
|
<div className="search-mini">
|
||||||
|
<Search size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by name or role..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="register-staff-btn-premium"
|
||||||
|
onClick={onOpenStaffModal}
|
||||||
|
>
|
||||||
|
<Plus size={16} /> Add New Staff
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="staff-table-premium">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Operator Name</th>
|
||||||
|
<th>Designation</th>
|
||||||
|
<th>Node Alignment</th>
|
||||||
|
<th>Auth Status</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allUsers
|
||||||
|
.filter(
|
||||||
|
(u) =>
|
||||||
|
u.hospitalId ===
|
||||||
|
selectedHospital?.rawUser?.hospitalId ||
|
||||||
|
u.organisationId ===
|
||||||
|
selectedHospital?.rawUser?.organisationId
|
||||||
|
)
|
||||||
|
.map((u, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ fontWeight: 800 }}>
|
||||||
|
{u.name || u.username}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="v-status IDLE"
|
||||||
|
style={{ fontSize: '0.6rem' }}
|
||||||
|
>
|
||||||
|
{u.roles[0]?.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ opacity: 0.7 }}>
|
||||||
|
{selectedHospital?.name}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="node-status-text ON">
|
||||||
|
AUTHORIZED
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => onEditStaff(u)}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: 'var(--accent-cyan)', cursor: 'pointer' }}
|
||||||
|
title="Edit Staff"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupSubTab === 'DEPTS' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
>
|
||||||
|
<Card title="Emergency Departments Node Registry">
|
||||||
|
<div className="setup-actions-bar">
|
||||||
|
<div className="search-mini">
|
||||||
|
<Search size={16} />
|
||||||
|
<input type="text" placeholder="Search departments..." />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="register-staff-btn-premium"
|
||||||
|
onClick={onOpenDeptModal}
|
||||||
|
>
|
||||||
|
<Plus size={16} /> ADD DEPARTMENT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-responsive-premium">
|
||||||
|
<table className="staff-table-premium">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Department / Clinical Node</th>
|
||||||
|
<th>Leadership</th>
|
||||||
|
<th>Resource Allocation</th>
|
||||||
|
<th>Communication</th>
|
||||||
|
<th>Ops Status</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{departments.map((d, i) => {
|
||||||
|
const occupancy = ((d.occupiedBeds || 0) / (d.totalBedsCapacity || 1)) * 100;
|
||||||
|
const isHighOccupancy = occupancy > 85;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.tr
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
>
|
||||||
|
<td style={{ fontWeight: 800 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
background: 'var(--accent-cyan-soft)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--accent-cyan)'
|
||||||
|
}}>
|
||||||
|
<Activity size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.95rem', color: '#1e293b' }}>{d.name}</div>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Node: {d.id?.substring(0, 8)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: '0.85rem' }}>{d.headOfDepartment}</span>
|
||||||
|
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>Chief of Medicine</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: '140px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', fontWeight: 700 }}>
|
||||||
|
<span style={{ color: isHighOccupancy ? '#ef4444' : '#64748b' }}>{d.occupiedBeds}/{d.totalBedsCapacity} Units</span>
|
||||||
|
<span style={{ color: '#94a3b8' }}>{Math.round(occupancy)}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: '6px', width: '100%', background: '#f1f5f9', borderRadius: '3px', overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${occupancy}%`,
|
||||||
|
background: isHighOccupancy ? '#ef4444' : 'var(--accent-cyan)',
|
||||||
|
borderRadius: '3px'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#10b981', fontWeight: 600 }}>{d.availableBeds} Units Available</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#475569', fontSize: '0.85rem' }}>
|
||||||
|
<Phone size={14} style={{ color: '#94a3b8' }} />
|
||||||
|
{d.contactPhone || 'No direct line'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`node-status-text ${d.isActive ? 'ON' : 'OFF'}`} style={{ padding: '4px 10px', borderRadius: '20px', fontSize: '0.65rem', display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<div style={{ width: 6, height: 6, borderRadius: '50%', background: d.isActive ? '#10b981' : '#94a3b8' }} />
|
||||||
|
{d.isActive ? 'OPERATIONAL' : 'INACTIVE'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
className={`toggle-btn-modern ${d.isActive ? 'disable' : 'enable'}`}
|
||||||
|
style={{ padding: '6px 12px', fontSize: '0.65rem', height: '30px' }}
|
||||||
|
onClick={() => {
|
||||||
|
const n = [...departments];
|
||||||
|
n[i].isActive = !n[i].isActive;
|
||||||
|
setDepartments(n);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d.isActive ? 'OFFLINE' : 'ONLINE'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon" style={{ background: '#f8fafc', borderRadius: '8px', padding: '6px' }}>
|
||||||
|
<Settings size={14} color="#64748b" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupSubTab === 'BEDS' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
>
|
||||||
|
<Card title="Inventory & Capacity Management">
|
||||||
|
<div className="setup-actions-bar">
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Active Care Units: {departments.length}
|
||||||
|
</p>
|
||||||
|
<button className="register-staff-btn-premium">
|
||||||
|
<Plus size={16} /> ADD UNIT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="staff-table-premium">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Care Unit / Dept</th>
|
||||||
|
<th>Total Capacity</th>
|
||||||
|
<th>Occupied</th>
|
||||||
|
<th>Availability</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{departments.map((b, i) => {
|
||||||
|
const occ = b.occupiedBeds || 0;
|
||||||
|
const tot = b.totalBedsCapacity || 1;
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ fontWeight: 800 }}>{b.name}</td>
|
||||||
|
<td>{b.totalBedsCapacity} Units</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent-cyan)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{occ}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="bed-progress"
|
||||||
|
style={{ width: '100px', marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${(occ / tot) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
389
src/pages/hospital/StaffModal.tsx
Normal file
389
src/pages/hospital/StaffModal.tsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { X, Eye, EyeOff, User, Shield, Briefcase, FileText } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StaffModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
staffFormData: any;
|
||||||
|
setStaffFormData: (data: any) => void;
|
||||||
|
rolesList: any[];
|
||||||
|
showPassword: boolean;
|
||||||
|
setShowPassword: (show: boolean) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StaffModal: React.FC<StaffModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
staffFormData,
|
||||||
|
setStaffFormData,
|
||||||
|
rolesList,
|
||||||
|
showPassword,
|
||||||
|
setShowPassword,
|
||||||
|
onSubmit,
|
||||||
|
isEditing = false,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const role = staffFormData.role || 'ED_DOCTOR';
|
||||||
|
|
||||||
|
const renderRoleSpecificFields = () => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ED_DOCTOR':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Specialization</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.specialization || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, specialization: e.target.value })}
|
||||||
|
placeholder="e.g. Emergency Medicine"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>License Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.license_number || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, license_number: e.target.value })}
|
||||||
|
placeholder="e.g. MC-998877"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Department</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.department || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, department: e.target.value })}
|
||||||
|
placeholder="e.g. Emergency Operations"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Designation</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.designation || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, designation: e.target.value })}
|
||||||
|
placeholder="e.g. ERCP Specialist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Shift Schedule</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.shift || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, shift: e.target.value })}
|
||||||
|
placeholder="e.g. Rotational"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Hospital Coordinator':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Department</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.department || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, department: e.target.value })}
|
||||||
|
placeholder="e.g. Triage Section"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Designation</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.designation || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, designation: e.target.value })}
|
||||||
|
placeholder="e.g. Quick Response Lead"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Shift Schedule</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.shift || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, shift: e.target.value })}
|
||||||
|
placeholder="e.g. DAY"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Languages Spoken (comma separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.languages || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, languages: e.target.value })}
|
||||||
|
placeholder="e.g. English, Tamil, Hindi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Hospital Nurse':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Employee ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.employee_id || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, employee_id: e.target.value })}
|
||||||
|
placeholder="e.g. MIO-N005"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Organization ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.org_id || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, org_id: e.target.value })}
|
||||||
|
placeholder="Auto-populated if left blank"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Department</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.department || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, department: e.target.value })}
|
||||||
|
placeholder="e.g. Emergency Room (ER)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Designation</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.designation || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, designation: e.target.value })}
|
||||||
|
placeholder="e.g. Staff Nurse (Grade II)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Shift Schedule</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.shift || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, shift: e.target.value })}
|
||||||
|
placeholder="e.g. NIGHT"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Assigned Floor / Ward</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.assigned_floor || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, assigned_floor: e.target.value })}
|
||||||
|
placeholder="e.g. 2nd Floor - Trauma Wing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Languages Spoken (comma separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.languages || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, languages: e.target.value })}
|
||||||
|
placeholder="e.g. English, Tamil"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Generic fallback fields
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Department</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.department || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, department: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Designation</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.designation || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, designation: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Shift Schedule</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.shift || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, shift: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="premium-modal-overlay">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
|
className="premium-modal-container"
|
||||||
|
style={{ maxWidth: '800px', width: '90%' }} // Made wider for two columns
|
||||||
|
>
|
||||||
|
<div className="modal-header-premium">
|
||||||
|
<h3>{isEditing ? 'Update Personnel' : 'New Staff Registration'}</h3>
|
||||||
|
<button className="modal-close-btn" onClick={onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setup-form-modern" style={{ maxHeight: '65vh', overflowY: 'auto', padding: '24px' }}>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '28px' }}>
|
||||||
|
|
||||||
|
{/* LEFT COLUMN: Identity & Auth */}
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '14px', paddingBottom: '8px', borderBottom: '1px solid var(--card-border)' }}>
|
||||||
|
<User size={16} style={{ color: 'var(--accent-cyan)' }} /> <h4 style={{ margin: 0, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)', fontWeight: 600, letterSpacing: '0.04em' }}>Identity & Access</h4>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Full Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.name}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, name: e.target.value })}
|
||||||
|
placeholder="e.g. Dr. John Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Organizational Role</label>
|
||||||
|
<select
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.role}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, role: e.target.value })}
|
||||||
|
>
|
||||||
|
{rolesList.length > 0 ? (
|
||||||
|
rolesList.map((r: any) => (
|
||||||
|
<option key={r.id} value={r.name}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="ED_DOCTOR">ED_DOCTOR</option>
|
||||||
|
<option value="Hospital Coordinator">Hospital Coordinator</option>
|
||||||
|
<option value="Hospital Nurse">Hospital Nurse</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Email Address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.email}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, email: e.target.value })}
|
||||||
|
placeholder="e.g. jsmith@hospital.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>Phone Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.phone}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, phone: e.target.value })}
|
||||||
|
placeholder="e.g. +919876543210"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>System Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="setup-input-premium"
|
||||||
|
value={staffFormData.username}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, username: e.target.value })}
|
||||||
|
placeholder="e.g. jsmith.ed"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-premium">
|
||||||
|
<label>{isEditing ? 'New Password (leave blank to keep current)' : 'Temporary Password'}</label>
|
||||||
|
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className="setup-input-premium"
|
||||||
|
style={{ width: '100%', paddingRight: '40px' }}
|
||||||
|
value={staffFormData.password || ''}
|
||||||
|
onChange={(e) => setStaffFormData({ ...staffFormData, password: e.target.value })}
|
||||||
|
placeholder={isEditing ? 'Enter new password...' : 'Password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', right: '14px', top: '50%', transform: 'translateY(-50%)',
|
||||||
|
background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 0, display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN: Role Specific Data */}
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '14px', paddingBottom: '8px', borderBottom: '1px solid var(--card-border)' }}>
|
||||||
|
<Briefcase size={16} style={{ color: 'var(--accent-cyan)' }} /> <h4 style={{ margin: 0, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)', fontWeight: 600, letterSpacing: '0.04em' }}>Professional Details</h4>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{renderRoleSpecificFields()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer-premium">
|
||||||
|
<button className="btn-secondary-glass" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary-glass" onClick={onSubmit}>
|
||||||
|
{isEditing ? 'Update Personnel' : 'Register Personnel'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
218
src/pages/hospital/TeleLinkHub.tsx
Normal file
218
src/pages/hospital/TeleLinkHub.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Video,
|
||||||
|
VideoOff,
|
||||||
|
Mic,
|
||||||
|
MicOff,
|
||||||
|
PhoneOff,
|
||||||
|
User,
|
||||||
|
Activity,
|
||||||
|
MessageSquare,
|
||||||
|
ClipboardList,
|
||||||
|
AlertCircle,
|
||||||
|
MoreVertical,
|
||||||
|
Shield,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
MessageCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card } from '../../components/Common';
|
||||||
|
|
||||||
|
export const TeleLinkHub: React.FC = () => {
|
||||||
|
const [activeCall, setActiveCall] = useState<any>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'CALL' | 'HISTORY'>('CALL');
|
||||||
|
|
||||||
|
const incomingRequests = [
|
||||||
|
{ id: 'TL-101', patient: 'Rajesh Khanna', triage: 'RED', wait: '1:45', complaint: 'Cardiac Arrest', symptoms: 'Ongoing CPR, 2 shocks delivered' },
|
||||||
|
{ id: 'TL-102', patient: 'Unknown Male', triage: 'RED', wait: '0:30', complaint: 'Potential Stroke', symptoms: 'GCS 12, unilateral weakness' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const callHistory = [
|
||||||
|
{ id: 'TL-098', date: '2026-05-04', duration: '12m 45s', patient: 'Amit Shah', outcome: 'Admission Admitted', emt: 'Arjun K.' },
|
||||||
|
{ id: 'TL-095', date: '2026-05-04', duration: '08m 20s', patient: 'Priya Verma', outcome: 'Consult Completed', emt: 'Suman R.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="module-content"
|
||||||
|
>
|
||||||
|
<div className="module-header-modern">
|
||||||
|
<div className="title-wrap">
|
||||||
|
<h3>TELELINK COMMAND CENTER</h3>
|
||||||
|
<div className="live-pill">
|
||||||
|
<span className="pulse" /> ERCP PHYSICIAN CONSOLE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tab-switcher-premium">
|
||||||
|
<button
|
||||||
|
className={`tab-btn ${activeTab === 'CALL' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('CALL')}
|
||||||
|
>
|
||||||
|
ACTIVE SESSIONS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-btn ${activeTab === 'HISTORY' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('HISTORY')}
|
||||||
|
>
|
||||||
|
CONSULT HISTORY
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'CALL' ? (
|
||||||
|
<div className="telelink-main-grid">
|
||||||
|
{/* Call Queue */}
|
||||||
|
<div className="call-queue-panel">
|
||||||
|
<div className="panel-header-mini">
|
||||||
|
<h4>INCOMING REQUESTS</h4>
|
||||||
|
<span className="count-badge">{incomingRequests.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="queue-list">
|
||||||
|
{incomingRequests.map(req => (
|
||||||
|
<div key={req.id} className={`queue-item triage-${req.triage.toLowerCase()}`}>
|
||||||
|
<div className="q-header">
|
||||||
|
<span className="q-id">{req.id}</span>
|
||||||
|
<span className="q-wait"><Clock size={12} /> {req.wait}</span>
|
||||||
|
</div>
|
||||||
|
<div className="q-body">
|
||||||
|
<div className="q-patient">{req.patient}</div>
|
||||||
|
<div className="q-complaint">{req.complaint}</div>
|
||||||
|
</div>
|
||||||
|
<div className="q-actions">
|
||||||
|
<button className="q-btn accept" onClick={() => setActiveCall(req)}>ACCEPT</button>
|
||||||
|
<button className="q-btn decline">DECLINE</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Call Surface */}
|
||||||
|
<div className="call-surface-panel-premium">
|
||||||
|
<div className="surface-glass-glow" />
|
||||||
|
|
||||||
|
{activeCall ? (
|
||||||
|
<div className="active-call-grid">
|
||||||
|
<div className="video-workspace">
|
||||||
|
<div className="video-feed-main">
|
||||||
|
<div className="feed-header-overlay">
|
||||||
|
<div className="secure-badge">
|
||||||
|
<Shield size={14} /> 256-BIT ENCRYPTED
|
||||||
|
</div>
|
||||||
|
<div className="call-duration">12:45</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="video-placeholder-premium">
|
||||||
|
<div className="video-pulse-icon">
|
||||||
|
<Video size={48} />
|
||||||
|
</div>
|
||||||
|
<p>INITIALIZING SECURE VIDEO FEED...</p>
|
||||||
|
<div className="patient-id-overlay">
|
||||||
|
<span className="p-triage" data-triage={activeCall.triage.toLowerCase()}>{activeCall.triage}</span>
|
||||||
|
<div className="p-name-stack">
|
||||||
|
<span className="name">{activeCall.patient}</span>
|
||||||
|
<span className="id">CASE: {activeCall.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="video-actions-floating">
|
||||||
|
<button className="v-float-btn"><Mic size={20} /></button>
|
||||||
|
<button className="v-float-btn active"><Video size={20} /></button>
|
||||||
|
<button className="v-float-btn end-session" onClick={() => setActiveCall(null)}><PhoneOff size={20} /></button>
|
||||||
|
<div className="v-divider-vertical" />
|
||||||
|
<button className="v-float-btn"><MessageCircle size={20} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="clinical-data-rail">
|
||||||
|
<div className="rail-section">
|
||||||
|
<div className="rail-head">
|
||||||
|
<Activity size={16} /> <h4>LIVE VITALS</h4>
|
||||||
|
</div>
|
||||||
|
<div className="vitals-mini-grid">
|
||||||
|
<div className="v-stat-card">
|
||||||
|
<label>HEART RATE</label>
|
||||||
|
<div className="val">102<small>BPM</small></div>
|
||||||
|
</div>
|
||||||
|
<div className="v-stat-card">
|
||||||
|
<label>SPO2</label>
|
||||||
|
<div className="val">94%</div>
|
||||||
|
</div>
|
||||||
|
<div className="v-stat-card">
|
||||||
|
<label>BP</label>
|
||||||
|
<div className="val">130/85</div>
|
||||||
|
</div>
|
||||||
|
<div className="v-stat-card warning">
|
||||||
|
<label>TEMP</label>
|
||||||
|
<div className="val">101.4<small>°F</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rail-section grow">
|
||||||
|
<div className="rail-head">
|
||||||
|
<ClipboardList size={16} /> <h4>TELE-CONSULT NOTES</h4>
|
||||||
|
</div>
|
||||||
|
<textarea className="clinical-input" placeholder="Type clinical advice, prescriptions, or observations..." />
|
||||||
|
<button className="commit-btn">COMMIT TO EPCR</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="idle-surface-placeholder">
|
||||||
|
<div className="idle-icon-wrap">
|
||||||
|
<VideoOff size={64} />
|
||||||
|
<div className="idle-ring" />
|
||||||
|
</div>
|
||||||
|
<h3>READY FOR UPLINK</h3>
|
||||||
|
<p>Select a pending triage request from the queue to bridge TeleLink session</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="history-panel-full">
|
||||||
|
<table className="staff-table-premium">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session ID & Date</th>
|
||||||
|
<th>Patient</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>EMT / Field Provider</th>
|
||||||
|
<th>Clinical Outcome</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{callHistory.map(h => (
|
||||||
|
<tr key={h.id}>
|
||||||
|
<td>
|
||||||
|
<div style={{ fontWeight: 700 }}>{h.id}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{h.date}</div>
|
||||||
|
</td>
|
||||||
|
<td>{h.patient}</td>
|
||||||
|
<td>{h.duration}</td>
|
||||||
|
<td>{h.emt}</td>
|
||||||
|
<td>
|
||||||
|
<span className="status-badge" style={{ background: 'rgba(16, 185, 129, 0.1)', color: 'var(--accent-green)' }}>
|
||||||
|
{h.outcome}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="tag-btn"><ClipboardList size={14} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
240
src/pages/hospital/TripManagement.tsx
Normal file
240
src/pages/hospital/TripManagement.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
Navigation,
|
||||||
|
Truck,
|
||||||
|
Video,
|
||||||
|
FileText,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface TripManagementProps {
|
||||||
|
tripsData: any[];
|
||||||
|
onDeleteTrip: (id: string) => void;
|
||||||
|
onOpenBooking: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TripManagement: React.FC<TripManagementProps> = ({
|
||||||
|
tripsData,
|
||||||
|
onDeleteTrip,
|
||||||
|
onOpenBooking,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="module-content"
|
||||||
|
>
|
||||||
|
<div className="module-header-modern">
|
||||||
|
<div className="title-wrap">
|
||||||
|
<h3>LIVE TRIP MANAGEMENT</h3>
|
||||||
|
<div className="live-pill">
|
||||||
|
<span className="pulse" /> 8 ACTIVE TRANSPORTS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-area-header">
|
||||||
|
<div className="search-mini">
|
||||||
|
<Search size={14} />
|
||||||
|
<input type="text" placeholder="Search Patient, MRN..." />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onOpenBooking}
|
||||||
|
className="register-staff-btn-premium"
|
||||||
|
>
|
||||||
|
<Plus size={18} />{' '}
|
||||||
|
<span className="hide-mobile">NEW DISPATCH</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-strip stats-grid-responsive">
|
||||||
|
<div className="stat-card-premium">
|
||||||
|
<span className="stat-label">Active Trips</span>
|
||||||
|
<div className="stat-value">{tripsData.length}</div>
|
||||||
|
<div className="stat-trend up">
|
||||||
|
<TrendingUp size={12} /> 25% vs last hour
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card-premium">
|
||||||
|
<span className="stat-label">Avg Arrival E.T.A</span>
|
||||||
|
<div className="stat-value">9.4m</div>
|
||||||
|
<div className="stat-trend down">
|
||||||
|
<Clock size={12} /> 10% vs last hour
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card-premium">
|
||||||
|
<span className="stat-label">I.F.T Pending Nodes</span>
|
||||||
|
<div className="stat-value">03</div>
|
||||||
|
<div
|
||||||
|
className="stat-trend"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Status: Operational
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="trip-mgmt-table-container">
|
||||||
|
<table className="staff-table-premium">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Accession & Patient</th>
|
||||||
|
<th>Trip Parameters</th>
|
||||||
|
<th>Fleet Asset</th>
|
||||||
|
<th>Live Progress Timeline</th>
|
||||||
|
<th>Operational Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tripsData.map((t) => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td>
|
||||||
|
<div className="trip-patient-ident">
|
||||||
|
<div className="trip-patient-main">{t.patient}</div>
|
||||||
|
<div className="trip-id-mono">
|
||||||
|
{t.id} · MRNID: {t.mrn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="route-cell"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="r-point">
|
||||||
|
<MapPin size={12} /> {t.origin}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="route-line"
|
||||||
|
style={{
|
||||||
|
height: '20px',
|
||||||
|
borderLeft: '2px dashed var(--card-border)',
|
||||||
|
margin: '4px 5px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="r-point">
|
||||||
|
<Navigation size={12} /> {t.destination}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="vehicle-crew-stack">
|
||||||
|
<div
|
||||||
|
className="vc-unit"
|
||||||
|
style={{
|
||||||
|
fontWeight: 900,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Truck size={14} /> {t.vehicle}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="vc-crew"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.crew}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="progress-cell" style={{ width: '240px' }}>
|
||||||
|
<div
|
||||||
|
className="eta-container"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`v-status ${
|
||||||
|
t.status.replace(' ', '') === 'ARRIVED'
|
||||||
|
? 'RUNNING'
|
||||||
|
: 'IDLE'
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
{t.status}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 900,
|
||||||
|
color: 'var(--accent-cyan)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.eta} REMAINING
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="trip-status-timeline">
|
||||||
|
<div
|
||||||
|
className={`ts-step ${t.step >= 1 ? 'completed' : ''}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`ts-line ${t.step >= 2 ? 'active' : ''}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`ts-step ${
|
||||||
|
t.step >= 2
|
||||||
|
? 'completed'
|
||||||
|
: t.step === 1
|
||||||
|
? 'active'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`ts-line ${t.step >= 3 ? 'active' : ''}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`ts-step ${
|
||||||
|
t.step >= 3
|
||||||
|
? 'completed'
|
||||||
|
: t.step === 2
|
||||||
|
? 'active'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="trip-action-group"
|
||||||
|
style={{ display: 'flex', gap: '8px' }}
|
||||||
|
>
|
||||||
|
<button className="tag-btn">
|
||||||
|
<Video size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="tag-btn">
|
||||||
|
<FileText size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tag-btn"
|
||||||
|
onClick={() => onDeleteTrip(t.id)}
|
||||||
|
style={{ color: 'var(--alert-red)' }}
|
||||||
|
>
|
||||||
|
<XCircle size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user