SEPSIS_ICU_MIMIC / frontend /src /components /dashboard /HospitalOverview.tsx
Expanic's picture
feat: Add About Sepsis tab and ICU Critical Alarm sound
c0d51b8
Raw
History Blame Contribute Delete
27.2 kB
"use client"
import React, { useState, useEffect, useCallback } from "react"
import { useCriticalAlarm } from "@/lib/useCriticalAlarm"
import { motion, AnimatePresence } from "framer-motion"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
AlertTriangle,
Activity,
Users,
TrendingUp,
Clock,
Heart,
Thermometer,
Droplets,
Brain,
ChevronRight,
RefreshCw,
Filter,
Search
} from "lucide-react"
interface PatientSummary {
stay_id: number
subject_id: number
age: number
gender: string
sepsis?: number
prediction?: {
sepsis: number
respiration: number
cardiovascular: number
renal: number
cns: number
}
}
interface StatsData {
total_patients: number
avg_age: number
gender_distribution: { Male: number; Female: number }
sepsis_cases: { Sepsis: number; Normal: number }
}
// Export types for parent component
export type { PatientSummary, StatsData }
interface HospitalOverviewProps {
onPatientSelect?: (stayId: number) => void
// Cached data props
cachedStats?: StatsData | null
cachedPatients?: PatientSummary[]
onDataLoaded?: (stats: StatsData | null, patients: PatientSummary[]) => void
}
export default function HospitalOverview({
onPatientSelect,
cachedStats,
cachedPatients,
onDataLoaded
}: HospitalOverviewProps) {
const [emergencyPatients, setEmergencyPatients] = useState<PatientSummary[]>(cachedPatients || [])
const [stats, setStats] = useState<StatsData | null>(cachedStats || null)
const [loading, setLoading] = useState(!cachedPatients || cachedPatients.length === 0)
const [refreshing, setRefreshing] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filterCritical, setFilterCritical] = useState(false)
// ICU Alarm for critical patient interactions
const { playAlarm, isMuted, toggleMute, isPlaying } = useCriticalAlarm()
const handlePatientClick = useCallback((stayId: number, prediction?: PatientSummary['prediction']) => {
// Play alarm when clicking on a critical patient
if (prediction && prediction.sepsis >= 0.7) {
playAlarm('critical')
}
onPatientSelect?.(stayId)
}, [playAlarm, onPatientSelect])
const fetchData = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true)
else {
try {
// Try to load from localStorage cache first
const cached = localStorage.getItem('sepsis_guard_overview_cache')
if (cached) {
const parsed = JSON.parse(cached)
const now = new Date().getTime()
// Cache valid for 24 hours (24 * 60 * 60 * 1000)
if (now - parsed.timestamp < 86400000) {
setStats(parsed.stats)
setEmergencyPatients(parsed.patients)
setLoading(false)
onDataLoaded?.(parsed.stats, parsed.patients)
return // Exit early since we used cache
}
}
} catch (e) {
console.error("Failed to read from cache:", e)
}
setLoading(true)
}
const endpoint = process.env.NEXT_PUBLIC_API_URL || '/api'
try {
// Fetch stats
const statsRes = await fetch(`${endpoint}/stats`)
let statsData: StatsData | null = null
if (statsRes.ok) {
statsData = await statsRes.json()
setStats(statsData)
}
// Fetch emergency patients
const emergencyRes = await fetch(`${endpoint}/patients/emergency?limit=50`)
if (emergencyRes.ok) {
const patients = await emergencyRes.json()
// Fetch predictions for each patient
const patientsWithPredictions = await Promise.all(
patients.slice(0, 20).map(async (p: PatientSummary) => {
try {
const predRes = await fetch(`${endpoint}/predict/${p.stay_id}?window_hours=6`, {
method: 'POST'
})
if (predRes.ok) {
const pred = await predRes.json()
return { ...p, prediction: pred }
}
} catch (e) {
console.error(`Failed to get prediction for ${p.stay_id}`)
}
return p
})
)
setEmergencyPatients(patientsWithPredictions)
try {
// Save to localStorage for future reloads
localStorage.setItem('sepsis_guard_overview_cache', JSON.stringify({
stats: statsData,
patients: patientsWithPredictions,
timestamp: new Date().getTime()
}))
} catch (e) {
console.error("Failed to save cache:", e)
}
// Notify parent to cache the data
onDataLoaded?.(statsData, patientsWithPredictions)
}
} catch (e) {
console.error("Failed to fetch data:", e)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [onDataLoaded])
useEffect(() => {
// Only fetch if no cached data
if (!cachedPatients || cachedPatients.length === 0) {
fetchData()
}
}, [cachedPatients, fetchData])
const handleRefresh = () => {
fetchData(true)
}
const getCriticalityLevel = (prediction?: PatientSummary['prediction']) => {
if (!prediction) return { level: 'unknown', color: 'slate', label: 'Unknown' }
const sepsisRisk = prediction.sepsis
if (sepsisRisk >= 0.7) return { level: 'critical', color: 'red', label: 'CRITICAL' }
if (sepsisRisk >= 0.4) return { level: 'high', color: 'orange', label: 'HIGH RISK' }
if (sepsisRisk >= 0.2) return { level: 'moderate', color: 'amber', label: 'MODERATE' }
return { level: 'low', color: 'emerald', label: 'STABLE' }
}
const filteredPatients = emergencyPatients.filter(p => {
const matchesSearch = searchQuery === "" ||
p.stay_id.toString().includes(searchQuery) ||
p.subject_id.toString().includes(searchQuery)
const criticality = getCriticalityLevel(p.prediction)
const matchesFilter = !filterCritical || criticality.level === 'critical' || criticality.level === 'high'
return matchesSearch && matchesFilter
})
// Sort by sepsis risk (highest first)
const sortedPatients = [...filteredPatients].sort((a, b) => {
const riskA = a.prediction?.sepsis ?? 0
const riskB = b.prediction?.sepsis ?? 0
return riskB - riskA
})
if (loading) {
return (
<div className="flex items-center justify-center h-[600px]">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="h-10 w-10 text-cyan-500 animate-spin" />
<p className="text-slate-400">Loading hospital data...</p>
</div>
</div>
)
}
return (
<div className="px-8 py-6 space-y-6 max-w-[1800px] mx-auto">
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-5">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0 }}
>
<Card className="glass-card border-slate-700/50 hover:border-cyan-500/30 transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-cyan-500/10">
<Users className="h-5 w-5 text-cyan-400" />
</div>
<div>
<p className="text-xs text-slate-500">Total Patients</p>
<p className="text-2xl font-bold text-white">{stats?.total_patients?.toLocaleString() ?? '-'}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="glass-card border-slate-700/50 hover:border-red-500/30 transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-red-500/10">
<AlertTriangle className="h-5 w-5 text-red-400" />
</div>
<div>
<p className="text-xs text-slate-500">Sepsis Cases</p>
<p className="text-2xl font-bold text-white">{stats?.sepsis_cases?.Sepsis?.toLocaleString() ?? '-'}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="glass-card border-slate-700/50 hover:border-emerald-500/30 transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-emerald-500/10">
<Activity className="h-5 w-5 text-emerald-400" />
</div>
<div>
<p className="text-xs text-slate-500">Normal Cases</p>
<p className="text-2xl font-bold text-white">{stats?.sepsis_cases?.Normal?.toLocaleString() ?? '-'}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card className="glass-card border-slate-700/50 hover:border-blue-500/30 transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-500/10">
<TrendingUp className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-xs text-slate-500">Avg. Age</p>
<p className="text-2xl font-bold text-white">{stats?.avg_age ?? '-'}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Emergency Patients Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="glass-card border-slate-700/50">
<CardHeader className="border-b border-slate-700/50 px-6 py-4">
<div className="flex flex-col lg:flex-row lg:items-center gap-4 justify-between">
<CardTitle className="flex items-center gap-2 text-white text-lg">
<AlertTriangle className="h-5 w-5 text-red-400" />
Emergency Patients
<span className="text-sm font-normal text-slate-500 ml-2">
({sortedPatients.length} patients)
</span>
</CardTitle>
<div className="flex items-center gap-2 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-[300px]">
{/* Search icon removed as requested */}
<input
type="text"
placeholder="Cari ID Pasien / Subject..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg bg-slate-800 border border-slate-600/50 text-base text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/30 shadow-none"
/>
</div>
{/* Filter */}
<button
onClick={() => setFilterCritical(!filterCritical)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${filterCritical
? 'bg-red-500/20 border border-red-500/50 text-red-400'
: 'bg-slate-800 border border-slate-600/50 text-slate-400 hover:border-slate-500'
}`}
>
<Filter className="h-4 w-4" />
Critical Only
</button>
{/* Refresh */}
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-2 rounded-lg bg-slate-800 border border-slate-600/50 hover:border-cyan-500/50 transition-all"
title="Refresh data"
>
<RefreshCw className={`h-4 w-4 text-slate-400 ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[500px] overflow-y-auto">
<table className="w-full">
<thead className="sticky top-0 bg-slate-900/95 backdrop-blur-sm">
<tr className="border-b border-slate-700/50">
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">STATUS</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">PATIENT ID</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">AGE/GENDER</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">SEPSIS RISK</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">SOFA SCORES</th>
<th className="text-right text-xs font-medium text-slate-500 px-4 py-3">ACTION</th>
</tr>
</thead>
<tbody>
<AnimatePresence>
{sortedPatients.map((patient, index) => {
const criticality = getCriticalityLevel(patient.prediction)
return (
<motion.tr
key={patient.stay_id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ delay: index * 0.02 }}
className="border-b border-slate-800/50 hover:bg-slate-800/30 transition-colors cursor-pointer"
onClick={() => handlePatientClick(patient.stay_id, patient.prediction)}
>
{/* Status */}
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${criticality.level === 'critical' ? 'bg-red-500/20 text-red-400' :
criticality.level === 'high' ? 'bg-orange-500/20 text-orange-400' :
criticality.level === 'moderate' ? 'bg-amber-500/20 text-amber-400' :
criticality.level === 'low' ? 'bg-emerald-500/20 text-emerald-400' :
'bg-slate-500/20 text-slate-400'
}`}>
<span className={`h-1.5 w-1.5 rounded-full ${criticality.level === 'critical' ? 'bg-red-400 animate-pulse' :
criticality.level === 'high' ? 'bg-orange-400 animate-pulse' :
criticality.level === 'moderate' ? 'bg-amber-400' :
criticality.level === 'low' ? 'bg-emerald-400' :
'bg-slate-400'
}`} />
{criticality.label}
</span>
</td>
{/* Patient ID */}
<td className="px-4 py-3">
<div>
<div className="font-medium text-white">Stay: {patient.stay_id}</div>
<div className="text-xs text-slate-500">Subject: {patient.subject_id}</div>
</div>
</td>
{/* Age/Gender */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-white">{patient.age} yrs</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${['M', 'Male', '0', 0].includes(patient.gender)
? 'bg-blue-500/20 text-blue-400'
: 'bg-pink-500/20 text-pink-400'
}`}>
{['M', 'Male', '0', 0].includes(patient.gender) ? '♂' : '♀'}
</span>
</div>
</td>
{/* Sepsis Risk */}
<td className="px-4 py-3">
{patient.prediction ? (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${patient.prediction.sepsis >= 0.7 ? 'bg-red-500' :
patient.prediction.sepsis >= 0.4 ? 'bg-orange-500' :
patient.prediction.sepsis >= 0.2 ? 'bg-amber-500' :
'bg-emerald-500'
}`}
style={{ width: `${Math.min(100, patient.prediction.sepsis * 100)}%` }}
/>
</div>
<span className="text-sm font-medium text-white">
{(patient.prediction.sepsis * 100).toFixed(1)}%
</span>
</div>
) : (
<span className="text-slate-500">-</span>
)}
</td>
{/* SOFA Scores */}
<td className="px-4 py-3">
{patient.prediction ? (
<div className="flex items-center gap-1">
<SOFABadge icon={<Heart className="h-3 w-3" />} value={patient.prediction.cardiovascular} />
<SOFABadge icon={<Droplets className="h-3 w-3" />} value={patient.prediction.renal} />
<SOFABadge icon={<Brain className="h-3 w-3" />} value={patient.prediction.cns} />
</div>
) : (
<span className="text-slate-500">-</span>
)}
</td>
{/* Action */}
<td className="px-4 py-3 text-right">
<button
onClick={(e) => {
e.stopPropagation()
handlePatientClick(patient.stay_id, patient.prediction)
}}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 text-xs font-medium hover:bg-cyan-500/20 transition-all"
>
View
<ChevronRight className="h-3 w-3" />
</button>
</td>
</motion.tr>
)
})}
</AnimatePresence>
</tbody>
</table>
{sortedPatients.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
<Search className="h-8 w-8 mb-2" />
<p>No patients found</p>
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
</div>
)
}
function SOFABadge({ icon, value }: { icon: React.ReactNode; value: number }) {
const severity = value >= 3 ? 'red' : value >= 2 ? 'amber' : value >= 1 ? 'yellow' : 'emerald'
return (
<div className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${severity === 'red' ? 'bg-red-500/20 text-red-400' :
severity === 'amber' ? 'bg-amber-500/20 text-amber-400' :
severity === 'yellow' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-emerald-500/20 text-emerald-400'
}`}>
{icon}
<span className="font-medium">{value.toFixed(1)}</span>
</div>
)
}