feat: initialize hospital dashboard foundation with navigation config, global design tokens, and core operational pages

This commit is contained in:
2026-05-12 22:50:29 +05:30
parent a1930c1bab
commit 8977f9e7fd
6 changed files with 593 additions and 9 deletions

View File

@@ -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 });
},
};

View File

@@ -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'] },

View File

@@ -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 {

View File

@@ -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)' },

View 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>
);
};

View File

@@ -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 };