feat: initialize hospital dashboard foundation with navigation config, global design tokens, and core operational pages
This commit is contained in:
@@ -73,14 +73,35 @@ export const hospitalApi = {
|
||||
/**
|
||||
* Get incoming operations/dispatches for the hospital.
|
||||
*/
|
||||
getIncomingOperations: async (token: string, hospitalId?: string) => {
|
||||
const url = hospitalId
|
||||
? `/v1/hospital/ops/incoming?hospitalId=${hospitalId}`
|
||||
: '/v1/hospital/ops/incoming';
|
||||
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.post(`/v1/hospital/ops/incoming/${patientId}/admit`, { departmentId }, { token });
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
LayoutGrid,
|
||||
Video,
|
||||
FileText,
|
||||
TrendingUp
|
||||
TrendingUp,
|
||||
BedDouble
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
@@ -96,6 +97,7 @@ export const NAVIGATION_CONFIG: NavItem[] = [
|
||||
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'] },
|
||||
|
||||
@@ -509,6 +509,9 @@ select, select option {
|
||||
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 {
|
||||
@@ -540,6 +543,10 @@ select, select option {
|
||||
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 {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Video,
|
||||
Hospital,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
BedDouble,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../components/Common';
|
||||
@@ -30,10 +32,13 @@ import { EPCRRecords } from './hospital/EPCRRecords';
|
||||
import { PatientArchive } from './hospital/PatientArchive';
|
||||
import { ReferralsSetup } from './hospital/ReferralsSetup';
|
||||
import { HospitalAnalytics } from './hospital/HospitalAnalytics';
|
||||
import { AdmissionsBoard } from './hospital/AdmissionsBoard';
|
||||
import './HospitalConsole.css';
|
||||
|
||||
type ConsoleModule =
|
||||
| 'HUB'
|
||||
| 'ED_MONITOR'
|
||||
| 'ADMISSIONS'
|
||||
| 'BOOKINGS'
|
||||
| 'FLEET'
|
||||
| 'TELELINK'
|
||||
@@ -60,7 +65,8 @@ export const HospitalConsole: React.FC = () => {
|
||||
const [selectedHospital, setSelectedHospital] = useState<any>(null);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeModule = (searchParams.get('tab') as ConsoleModule) || 'ED_MONITOR';
|
||||
const activeTab = searchParams.get('tab') as ConsoleModule | null;
|
||||
const activeModule = activeTab || 'HUB';
|
||||
const setActiveModule = (key: ConsoleModule) => {
|
||||
setSearchParams({ tab: key });
|
||||
};
|
||||
@@ -528,6 +534,75 @@ export const HospitalConsole: React.FC = () => {
|
||||
// ── Module renderer ──
|
||||
const renderModule = () => {
|
||||
switch (activeModule) {
|
||||
case 'HUB':
|
||||
return (
|
||||
<div className="ops-hub-grid" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: '24px',
|
||||
padding: '20px 0'
|
||||
}}>
|
||||
{MODULE_CARDS.map(card => (
|
||||
<motion.div
|
||||
key={card.key}
|
||||
whileHover={{ y: -5, scale: 1.02 }}
|
||||
onClick={() => setSearchParams({ tab: card.key })}
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: '20px',
|
||||
padding: '24px',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
boxShadow: 'var(--shadow-sm)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '12px',
|
||||
background: card.accentBg,
|
||||
color: card.accent,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<card.icon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800 }}>{card.label}</h3>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
color: card.accent
|
||||
}}>
|
||||
Open Module <ChevronRight size={14} />
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '4px',
|
||||
height: '100%',
|
||||
background: card.accent
|
||||
}} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'ED_MONITOR':
|
||||
return (
|
||||
<EDMonitor
|
||||
@@ -543,6 +618,15 @@ export const HospitalConsole: React.FC = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'ADMISSIONS':
|
||||
return (
|
||||
<AdmissionsBoard
|
||||
onRefresh={() => {
|
||||
window.dispatchEvent(new CustomEvent('refresh_incoming'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'BOOKINGS':
|
||||
return (
|
||||
<TripManagement
|
||||
@@ -608,6 +692,7 @@ export const HospitalConsole: React.FC = () => {
|
||||
// Module card definitions with descriptions and color accents
|
||||
const MODULE_CARDS: { key: ConsoleModule; icon: any; label: string; description: string; accent: string; accentBg: string }[] = [
|
||||
{ key: 'ED_MONITOR', icon: Monitor, label: 'ED Monitor', description: 'Live triage stream, incoming patients & emergency broadcasts', accent: 'hsl(0, 84%, 60%)', accentBg: 'hsla(0, 84%, 60%, 0.08)' },
|
||||
{ key: 'ADMISSIONS', icon: BedDouble, label: 'Admissions', description: 'Admitted patients, ward management & discharge workflow', accent: 'hsl(262, 83%, 58%)', accentBg: 'hsla(262, 83%, 58%, 0.08)' },
|
||||
{ key: 'BOOKINGS', icon: Activity, label: 'Trip Management', description: 'Ambulance bookings, dispatches & trip tracking', accent: 'hsl(199, 89%, 48%)', accentBg: 'hsla(199, 89%, 48%, 0.08)' },
|
||||
{ key: 'FLEET', icon: Truck, label: 'Fleet Visibility', description: 'Real-time fleet map, vehicle status & route monitoring', accent: 'hsl(262, 83%, 58%)', accentBg: 'hsla(262, 83%, 58%, 0.08)' },
|
||||
{ key: 'TELELINK', icon: Video, label: 'TeleLink Hub', description: 'Video consults, physician console & remote triage', accent: 'hsl(152, 69%, 40%)', accentBg: 'hsla(152, 69%, 40%, 0.08)' },
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -85,7 +85,7 @@ export const EDMonitor: React.FC<EDMonitorProps> = ({
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
total: incomingPatients.filter(p => p.status === 'IN_TRANSIT').length,
|
||||
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,
|
||||
@@ -106,7 +106,7 @@ export const EDMonitor: React.FC<EDMonitorProps> = ({
|
||||
});
|
||||
}, [incomingPatients, searchQuery, statusFilter]);
|
||||
|
||||
const statuses = ['ALL', 'IN_TRANSIT', 'ARRIVED', 'HANDOFF_COMPLETE'];
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user