Spaces:
Sleeping
Sleeping
| "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> | |
| ) | |
| } | |