525 lines
21 KiB
TypeScript
525 lines
21 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
|
import { Activity, AlertTriangle, Download, Pause, Play, Settings, User } from "lucide-react"
|
|
|
|
// Mock data generator
|
|
const generateTimeSeriesData = (hours = 24) => {
|
|
const data = []
|
|
const now = new Date()
|
|
|
|
for (let i = hours * 60; i >= 0; i -= 5) {
|
|
const time = new Date(now.getTime() - i * 60 * 1000)
|
|
data.push({
|
|
time: time.toLocaleTimeString("en-US", { hour12: false }),
|
|
timestamp: time.getTime(),
|
|
pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40,
|
|
})
|
|
}
|
|
return data
|
|
}
|
|
|
|
// Sensor configuration interface
|
|
interface SensorConfig {
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
zone: string;
|
|
label: string;
|
|
pin?: number;
|
|
}
|
|
|
|
const getPressureColor = (pressure: number) => {
|
|
if (pressure < 30) return "#22c55e" // Green - Low pressure
|
|
if (pressure < 50) return "#eab308" // Yellow - Medium pressure
|
|
if (pressure < 70) return "#f97316" // Orange - High pressure
|
|
return "#ef4444" // Red - Very high pressure
|
|
}
|
|
|
|
const getPressureLevel = (pressure: number) => {
|
|
if (pressure < 30) return "Low"
|
|
if (pressure < 50) return "Medium"
|
|
if (pressure < 70) return "High"
|
|
return "Critical"
|
|
}
|
|
|
|
interface SensorData {
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
zone: string;
|
|
label: string;
|
|
currentPressure: number;
|
|
data: Array<{ time: string; timestamp: number; pressure: number }>;
|
|
status: string;
|
|
source?: 'hardware' | 'mock';
|
|
pin?: number;
|
|
digitalState?: number;
|
|
}
|
|
|
|
export default function Component() {
|
|
const [sensorData, setSensorData] = useState<Record<string, SensorData>>({})
|
|
const [sensorConfig, setSensorConfig] = useState<SensorConfig[]>([])
|
|
const [selectedSensor, setSelectedSensor] = useState<string | null>(null)
|
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
|
const [isMonitoring, setIsMonitoring] = useState(true)
|
|
const [alerts, setAlerts] = useState<Array<{ id: string; message: string; time: string }>>([])
|
|
|
|
// Initialize sensor configuration
|
|
useEffect(() => {
|
|
const fetchSensorConfig = async () => {
|
|
try {
|
|
const response = await fetch('/api/sensors/config')
|
|
const data = await response.json()
|
|
|
|
if (data.success && data.sensors) {
|
|
setSensorConfig(data.sensors)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch sensor config:', error)
|
|
}
|
|
}
|
|
|
|
fetchSensorConfig()
|
|
}, [])
|
|
|
|
// Initialize sensor data
|
|
useEffect(() => {
|
|
if (sensorConfig.length === 0) return
|
|
|
|
const initialData: Record<string, SensorData> = {}
|
|
sensorConfig.forEach((sensor) => {
|
|
initialData[sensor.id] = {
|
|
...sensor,
|
|
currentPressure: Math.random() * 100,
|
|
data: generateTimeSeriesData(),
|
|
status: "normal",
|
|
}
|
|
})
|
|
setSensorData(initialData)
|
|
}, [sensorConfig])
|
|
|
|
// Fetch sensor data from API
|
|
useEffect(() => {
|
|
if (!isMonitoring) return
|
|
|
|
const fetchSensorData = async () => {
|
|
try {
|
|
const response = await fetch('/api/sensors')
|
|
const data = await response.json()
|
|
|
|
if (data.success && data.sensors) {
|
|
setSensorData((prev) => {
|
|
const updated = { ...prev }
|
|
const newAlerts: Array<{ id: string; message: string; time: string }> = []
|
|
|
|
data.sensors.forEach((sensor: {
|
|
id: string;
|
|
label: string;
|
|
zone: string;
|
|
pressure: number;
|
|
source: 'hardware' | 'mock';
|
|
pin?: number;
|
|
digitalState?: number;
|
|
}) => {
|
|
const currentSensor = updated[sensor.id]
|
|
const newPressure = sensor.pressure
|
|
|
|
// Check for alerts
|
|
if (newPressure > 80 && currentSensor && currentSensor.currentPressure <= 80) {
|
|
newAlerts.push({
|
|
id: `${sensor.id}-${Date.now()}`,
|
|
message: `High pressure detected at ${sensor.label}`,
|
|
time: new Date().toLocaleTimeString(),
|
|
})
|
|
}
|
|
|
|
// Update sensor data
|
|
updated[sensor.id] = {
|
|
...sensorConfig.find(s => s.id === sensor.id),
|
|
id: sensor.id,
|
|
x: sensorConfig.find(s => s.id === sensor.id)?.x || 50,
|
|
y: sensorConfig.find(s => s.id === sensor.id)?.y || 50,
|
|
zone: sensor.zone,
|
|
label: sensor.label,
|
|
currentPressure: newPressure,
|
|
data: currentSensor ? [
|
|
...currentSensor.data.slice(1),
|
|
{
|
|
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
|
timestamp: Date.now(),
|
|
pressure: newPressure,
|
|
},
|
|
] : [{
|
|
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
|
timestamp: Date.now(),
|
|
pressure: newPressure,
|
|
}],
|
|
status: newPressure > 80 ? "critical" : newPressure > 60 ? "warning" : "normal",
|
|
source: sensor.source,
|
|
pin: sensor.pin,
|
|
digitalState: sensor.digitalState
|
|
}
|
|
})
|
|
|
|
if (newAlerts.length > 0) {
|
|
setAlerts((prev) => [...newAlerts, ...prev].slice(0, 10))
|
|
}
|
|
|
|
return updated
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch sensor data:', error)
|
|
}
|
|
}
|
|
|
|
// Initial fetch
|
|
fetchSensorData()
|
|
|
|
// Set up polling
|
|
const interval = setInterval(fetchSensorData, 2000)
|
|
|
|
return () => clearInterval(interval)
|
|
}, [isMonitoring, sensorConfig])
|
|
|
|
const averagePressure =
|
|
Object.values(sensorData).reduce((sum: number, sensor: SensorData) => sum + (sensor.currentPressure || 0), 0) /
|
|
Object.keys(sensorData).length
|
|
|
|
const criticalSensors = Object.values(sensorData).filter((sensor: SensorData) => sensor.currentPressure > 80).length
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-6">
|
|
<div className="max-w-7xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Activity className="w-8 h-8 text-blue-600" />
|
|
<h1 className="text-3xl font-bold text-gray-900">Bed Pressure Monitor</h1>
|
|
</div>
|
|
<Badge variant={isMonitoring ? "default" : "secondary"} className="px-3 py-1">
|
|
{isMonitoring ? "Live" : "Paused"}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => setIsMonitoring(!isMonitoring)}>
|
|
{isMonitoring ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
|
{isMonitoring ? "Pause" : "Resume"}
|
|
</Button>
|
|
<Button variant="outline" size="sm">
|
|
<Download className="w-4 h-4" />
|
|
Export
|
|
</Button>
|
|
<Button variant="outline" size="sm">
|
|
<Settings className="w-4 h-4" />
|
|
Settings
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2">
|
|
<User className="w-5 h-5 text-blue-600" />
|
|
<div>
|
|
<p className="text-sm text-gray-600">Patient</p>
|
|
<p className="font-semibold">John Doe - Room 204</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-center">
|
|
<p className="text-sm text-gray-600">Average Pressure</p>
|
|
<p className="text-2xl font-bold" style={{ color: getPressureColor(averagePressure) }}>
|
|
{averagePressure.toFixed(1)} mmHg
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-center">
|
|
<p className="text-sm text-gray-600">Active Sensors</p>
|
|
<p className="text-2xl font-bold text-green-600">{Object.keys(sensorData).length}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className={`w-5 h-5 ${criticalSensors > 0 ? "text-red-600" : "text-gray-400"}`} />
|
|
<div>
|
|
<p className="text-sm text-gray-600">Critical Alerts</p>
|
|
<p className={`text-2xl font-bold ${criticalSensors > 0 ? "text-red-600" : "text-gray-600"}`}>
|
|
{criticalSensors}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Bed Visualization */}
|
|
<div className="lg:col-span-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Pressure Distribution Map</CardTitle>
|
|
<p className="text-sm text-gray-600">Click on any sensor point to view detailed pressure graphs</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="relative">
|
|
{/* Bed outline */}
|
|
<svg viewBox="0 0 100 100" className="w-full h-96 border-2 border-gray-200 rounded-lg bg-white">
|
|
{/* Bed frame */}
|
|
<rect x="25" y="10" width="50" height="80" fill="none" stroke="#e5e7eb" strokeWidth="2" rx="8" />
|
|
|
|
{/* Pillow area */}
|
|
<rect x="30" y="12" width="40" height="15" fill="#f3f4f6" stroke="#d1d5db" strokeWidth="1" rx="4" />
|
|
|
|
{/* Pressure sensors */}
|
|
{sensorConfig.map((sensor) => {
|
|
const sensorInfo = sensorData[sensor.id]
|
|
if (!sensorInfo) return null
|
|
|
|
return (
|
|
<circle
|
|
key={sensor.id}
|
|
cx={sensor.x}
|
|
cy={sensor.y}
|
|
r="3"
|
|
fill={getPressureColor(sensorInfo.currentPressure)}
|
|
stroke="white"
|
|
strokeWidth="1"
|
|
className="cursor-pointer transition-all duration-200 hover:r-4 hover:opacity-80"
|
|
onClick={() => {
|
|
setSelectedSensor(sensor.id)
|
|
setIsModalOpen(true)
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
</svg>
|
|
|
|
{/* Pressure Legend */}
|
|
<div className="mt-4 flex items-center justify-center gap-6 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
|
<span>Low ({"<"}30)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
|
<span>Medium (30-50)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
|
|
<span>High (50-70)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
|
<span>Critical ({">"}70)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Sensor Details & Alerts */}
|
|
<div className="space-y-6">
|
|
{/* Pressure Graph Modal */}
|
|
{isModalOpen && selectedSensor && sensorData[selectedSensor] && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900">{sensorData[selectedSensor].label}</h2>
|
|
<p className="text-gray-600">Pressure Monitoring Details</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsModalOpen(false)}
|
|
className="text-gray-500 hover:text-gray-700"
|
|
>
|
|
✕
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<p className="text-sm text-gray-600">Current Pressure</p>
|
|
<p
|
|
className="text-3xl font-bold"
|
|
style={{ color: getPressureColor(sensorData[selectedSensor].currentPressure) }}
|
|
>
|
|
{sensorData[selectedSensor].currentPressure.toFixed(1)}
|
|
</p>
|
|
<p className="text-sm text-gray-500">mmHg</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<p className="text-sm text-gray-600">Status Level</p>
|
|
<Badge
|
|
className="text-lg px-3 py-1 mt-2"
|
|
variant={
|
|
sensorData[selectedSensor].status === "critical"
|
|
? "destructive"
|
|
: sensorData[selectedSensor].status === "warning"
|
|
? "secondary"
|
|
: "default"
|
|
}
|
|
>
|
|
{getPressureLevel(sensorData[selectedSensor].currentPressure)}
|
|
</Badge>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<p className="text-sm text-gray-600">Body Zone</p>
|
|
<p className="text-xl font-semibold capitalize mt-2">{sensorData[selectedSensor].zone}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Large Pressure Chart */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>24-Hour Pressure Trend</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-80">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={sensorData[selectedSensor].data}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="time" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
|
|
<YAxis
|
|
tick={{ fontSize: 12 }}
|
|
label={{ value: "Pressure (mmHg)", angle: -90, position: "insideLeft" }}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value: number) => [`${value.toFixed(1)} mmHg`, "Pressure"]}
|
|
labelFormatter={(label) => `Time: ${label}`}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="pressure"
|
|
stroke={getPressureColor(sensorData[selectedSensor].currentPressure)}
|
|
strokeWidth={3}
|
|
dot={false}
|
|
activeDot={{ r: 6 }}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Additional Statistics */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<p className="text-sm text-gray-600">Max Pressure</p>
|
|
<p className="text-lg font-bold text-red-600">
|
|
{Math.max(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<p className="text-sm text-gray-600">Min Pressure</p>
|
|
<p className="text-lg font-bold text-green-600">
|
|
{Math.min(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<p className="text-sm text-gray-600">Average</p>
|
|
<p className="text-lg font-bold text-blue-600">
|
|
{(
|
|
sensorData[selectedSensor].data.reduce((sum: number, d: { time: string; timestamp: number; pressure: number }) => sum + d.pressure, 0) /
|
|
sensorData[selectedSensor].data.length
|
|
).toFixed(1)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<p className="text-sm text-gray-600">Data Points</p>
|
|
<p className="text-lg font-bold text-gray-600">{sensorData[selectedSensor].data.length}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 mt-6">
|
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
|
Close
|
|
</Button>
|
|
<Button>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Export Data
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Alerts */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
Recent Alerts
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3 max-h-64 overflow-y-auto">
|
|
{alerts.length === 0 ? (
|
|
<p className="text-sm text-gray-500 text-center py-4">No recent alerts</p>
|
|
) : (
|
|
alerts.map((alert) => (
|
|
<div
|
|
key={alert.id}
|
|
className="flex items-start gap-3 p-3 bg-red-50 rounded-lg border border-red-200"
|
|
>
|
|
<AlertTriangle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-red-800">{alert.message}</p>
|
|
<p className="text-xs text-red-600">{alert.time}</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|