From f0081f5ac137c2f9fd3cbe5262bdf846273135c7 Mon Sep 17 00:00:00 2001 From: Kavindi487 Date: Mon, 16 Mar 2026 11:58:49 +0530 Subject: [PATCH 1/9] updated --- .../PatientPortal/RealtimeGraphs.jsx | 597 +++++++++++------- 1 file changed, 363 insertions(+), 234 deletions(-) diff --git a/src/components/PatientPortal/RealtimeGraphs.jsx b/src/components/PatientPortal/RealtimeGraphs.jsx index 906e6e6..9c7d3c6 100644 --- a/src/components/PatientPortal/RealtimeGraphs.jsx +++ b/src/components/PatientPortal/RealtimeGraphs.jsx @@ -1,11 +1,41 @@ -import { useEffect, useState, useCallback } from "react"; -import { LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer } from "recharts"; -import { Activity, Thermometer, Heart, Droplet, Wind, Waves } from "lucide-react"; - -const BACKEND_URL = import.meta.env.VITE_IOT_URL; - -// Metric Card Component -function MetricCard({ icon: Icon, label, value, unit, status, statusColor }) { +import { useEffect, useState, useCallback, useRef } from "react"; +import { + LineChart, Line, XAxis, YAxis, Tooltip, + CartesianGrid, ResponsiveContainer, ReferenceLine +} from "recharts"; +import { + Activity, Thermometer, Heart, Droplet, + Wind, Waves, Wifi, WifiOff, RefreshCw +} from "lucide-react"; + +// ─── URL helper ─────────────────────────────────────────────────────────────── +// Works for localhost AND hosted deployments automatically. +// Set VITE_IOT_URL in your .env for local dev. +// In production (Azure / Vercel / etc.) set the same env var to your hosted gateway URL. +const BACKEND_URL = (import.meta.env.VITE_IOT_URL || "").replace(/\/$/, ""); + +// ─── Token helper — reads from whatever key your auth stores it ─────────────── +const getToken = () => + localStorage.getItem("token") || + localStorage.getItem("authToken") || + sessionStorage.getItem("token") || + ""; + +// ─── Authenticated fetch wrapper ───────────────────────────────────────────── +const authFetch = (url, options = {}) => { + const token = getToken(); + return fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }, + }); +}; + +// ─── Metric Card ───────────────────────────────────────────────────────────── +function MetricCard({ icon: Icon, label, value, unit, status, statusColor, pulse }) { return (
@@ -19,7 +49,13 @@ function MetricCard({ icon: Icon, label, value, unit, status, statusColor }) {
- {value} + + {value} + {unit}
{status && ( @@ -33,8 +69,8 @@ function MetricCard({ icon: Icon, label, value, unit, status, statusColor }) { ); } -// Graph Card Component -function GraphCard({ data, dataKey, label, icon: Icon, unit }) { +// ─── Graph Card ─────────────────────────────────────────────────────────────── +function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) { return (
@@ -43,235 +79,362 @@ function GraphCard({ data, dataKey, label, icon: Icon, unit }) {

{label}

+ {data.length > 0 && ( + + {data.length} readings + + )}
-
- - - - - - - - - - - - - new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - } - stroke="#9ca3af" - style={{ fontSize: "11px" }} - /> - - - - new Date(v).toLocaleString()} - formatter={(value) => [`${value} ${unit}`, label]} - /> - - - - + {data.length === 0 ? ( +
+ Waiting for sensor data… +
+ ) : ( + + + + + new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + } + stroke="#9ca3af" + style={{ fontSize: "11px" }} + /> + + new Date(v).toLocaleString()} + formatter={(value) => [`${value} ${unit}`, label]} + /> + {refLines?.map((r) => ( + + ))} + + + + )}
); } +// ─── Connection badge ───────────────────────────────────────────────────────── +function ConnectionBadge({ mode }) { + if (mode === "sse") + return ( + + + Live stream + + ); + if (mode === "polling") + return ( + + + Polling 5s + + ); + return ( + + + Offline + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── function RealtimeGraphs() { - const [history, setHistory] = useState([]); - const [currentData, setCurrentData] = useState(null); - const [loading, setLoading] = useState(true); + const [history, setHistory] = useState([]); + const [currentData, setCurrent] = useState(null); + const [loading, setLoading] = useState(true); + const [connMode, setConnMode] = useState("polling"); // "sse" | "polling" | "offline" + const [pulse, setPulse] = useState(false); + const [error, setError] = useState(null); + + const readerRef = useRef(null); + const pollerRef = useRef(null); + + // ── Append one new reading to history (keep last 100 points) ────────────── + const appendReading = useCallback((reading) => { + setHistory((prev) => { + const next = [...prev, reading]; + return next.slice(-100); + }); + setCurrent(reading); + setPulse(true); + setTimeout(() => setPulse(false), 600); + }, []); + // ── Fetch history (REST) — used as initial load + polling fallback ───────── const fetchHistory = useCallback(async () => { try { - const res = await fetch(`${BACKEND_URL}/api/sensordata/latest/50`); - if (res.ok) { - const data = await res.json(); - const reversed = Array.isArray(data) ? [...data].reverse() : []; - setHistory(reversed); - if (reversed.length > 0) { - setCurrentData(reversed[reversed.length - 1]); - } + const res = await authFetch( + `${BACKEND_URL}/api/sensordata/latest/50` + ); + if (res.status === 401) { + setError("Session expired — please log in again."); + setConnMode("offline"); + return; } - } catch (error) { - console.error("Error fetching data:", error); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const sorted = Array.isArray(data) ? [...data].reverse() : []; + setHistory(sorted.slice(-100)); + if (sorted.length > 0) setCurrent(sorted[sorted.length - 1]); + setError(null); + } catch (e) { + console.error("Fetch error:", e); + setConnMode("offline"); + setError("Cannot reach server. Check your connection."); } }, []); + // ── SSE stream (real-time push from backend) ─────────────────────────────── + const startSseStream = useCallback(async () => { + const token = getToken(); + if (!token) return false; // no token — fall back to polling + + try { + const res = await fetch(`${BACKEND_URL}/api/sensordata/stream`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok || !res.body) return false; + + setConnMode("sse"); + const reader = res.body.getReader(); + readerRef.current = reader; + const decoder = new TextDecoder(); + + // Read stream in a loop + (async () => { + let buffer = ""; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // SSE messages are separated by double newlines + const parts = buffer.split("\n\n"); + buffer = parts.pop() || ""; + + for (const part of parts) { + const dataLine = part.split("\n").find((l) => l.startsWith("data:")); + if (!dataLine) continue; + const jsonStr = dataLine.slice(5).trim(); + if (!jsonStr || jsonStr === "ping") continue; + try { + const reading = JSON.parse(jsonStr); + appendReading(reading); + } catch (_) {} + } + } + } catch (_) { + // Stream broke — fall back to polling + } + setConnMode("polling"); + startPolling(); + })(); + + return true; + } catch (_) { + return false; + } + }, [appendReading]); + + // ── Polling fallback (every 5s) ──────────────────────────────────────────── + const startPolling = useCallback(() => { + if (pollerRef.current) clearInterval(pollerRef.current); + setConnMode("polling"); + pollerRef.current = setInterval(fetchHistory, 5000); + }, [fetchHistory]); + + // ── Bootstrap ───────────────────────────────────────────────────────────── useEffect(() => { - let active = true; + let cancelled = false; - const loadInitial = async () => { + (async () => { await fetchHistory(); - if (active) setLoading(false); - }; + if (cancelled) return; + setLoading(false); - loadInitial(); - const interval = setInterval(fetchHistory, 5000); + const gotStream = await startSseStream(); + if (!gotStream && !cancelled) { + startPolling(); + } + })(); return () => { - active = false; - clearInterval(interval); + cancelled = true; + readerRef.current?.cancel(); + clearInterval(pollerRef.current); }; - }, [fetchHistory]); + }, [fetchHistory, startSseStream, startPolling]); + + // ── Status helpers ───────────────────────────────────────────────────────── + const hrStatus = (bpm) => { + if (!bpm || bpm === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; + if (bpm < 60) return { text: "Low", color: "bg-yellow-100 text-yellow-700" }; + if (bpm > 100) return { text: "High", color: "bg-red-100 text-red-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + }; + const spo2Status = (v) => { + if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; + if (v < 95) return { text: "Low", color: "bg-orange-100 text-orange-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + }; + const tempStatus = (t) => { + if (!t || t === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; + if (t < 36.1) return { text: "Low", color: "bg-blue-100 text-blue-700" }; + if (t > 37.2) return { text: "High", color: "bg-red-100 text-red-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + }; + const hr = hrStatus(currentData?.avgBpm); + const sp = spo2Status(currentData?.spo2); + const bt = tempStatus(currentData?.waterTempC); + + // ── Loading screen ──────────────────────────────────────────────────────── if (loading) { return (
-
-

Loading patient monitoring...

+
+

+ Loading patient monitoring… +

); } - const getHeartRateStatus = (bpm) => { - if (!bpm || bpm === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (bpm < 60) return { text: "Low", color: "bg-yellow-100 text-yellow-700" }; - if (bpm > 100) return { text: "High", color: "bg-red-100 text-red-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; - }; - - const getSpo2Status = (spo2) => { - if (!spo2 || spo2 === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (spo2 < 95) return { text: "Low", color: "bg-orange-100 text-orange-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; - }; - - const getBodyTempStatus = (temp) => { - if (!temp || temp === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (temp < 36.1) return { text: "Low", color: "bg-blue-100 text-blue-700" }; - if (temp > 37.2) return { text: "High", color: "bg-red-100 text-red-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; - }; - - const hrStatus = currentData ? getHeartRateStatus(currentData.avgBpm) : { text: "No Data", color: "bg-gray-100 text-gray-600" }; - const spo2Status = currentData ? getSpo2Status(currentData.spo2) : { text: "No Data", color: "bg-gray-100 text-gray-600" }; - const bodyTempStatus = currentData ? getBodyTempStatus(currentData.waterTempC) : { text: "No Data", color: "bg-gray-100 text-gray-600" }; - return (
-
+ {/* Error banner */} + {error && ( +
+ + {error} +
+ )} + {/* Patient Vital Signs */} -
+

Patient Vital Signs

-
- - - -
-
+ - {/* Environmental Conditions */} -
+ {/* Environmental */} +

Environmental Conditions

-
- - -
-
+ - {/* Real-Time Monitoring Graphs */} -
+ {/* Real-time graphs */} +

Real-Time Monitoring

-
- - -
-
-
+ - {/* Health Reference Ranges */} + {/* Reference ranges */}

Health Reference Ranges

- -
-

- - Heart Rate (BPM) -

-
    -
  • - - Normal: 60-100 -
  • -
  • - - Low: <60 -
  • -
  • - - High: >100 -
  • -
-
- -
-

- - SpO2 (%) -

-
    -
  • - - Normal: 95-100% -
  • -
  • - - Low: 90-94% -
  • -
  • - - Critical: <90% -
  • -
-
- -
-

- - Body Temperature (°C) -

-
    -
  • - - Normal: 36.1-37.2 -
  • -
  • - - Low: <36.1 -
  • -
  • - - High: >37.2 -
  • -
-
- -
-

- - Room Humidity (%) -

-
    -
  • - - Optimal: 30-60% -
  • -
  • - - Dry: <30% -
  • -
  • - - Humid: >60% -
  • -
-
- + {[ + { + icon: Heart, label: "Heart Rate (BPM)", + items: [ + { dot: "bg-teal-500", text: "Normal: 60–100" }, + { dot: "bg-yellow-500", text: "Low: <60" }, + { dot: "bg-red-500", text: "High: >100" }, + ], + }, + { + icon: Wind, label: "SpO2 (%)", + items: [ + { dot: "bg-teal-500", text: "Normal: 95–100%" }, + { dot: "bg-orange-500", text: "Low: 90–94%" }, + { dot: "bg-red-500", text: "Critical: <90%" }, + ], + }, + { + icon: Waves, label: "Body Temperature (°C)", + items: [ + { dot: "bg-teal-500", text: "Normal: 36.1–37.2" }, + { dot: "bg-blue-500", text: "Low: <36.1" }, + { dot: "bg-red-500", text: "High: >37.2" }, + ], + }, + { + icon: Droplet, label: "Room Humidity (%)", + items: [ + { dot: "bg-teal-500", text: "Optimal: 30–60%" }, + { dot: "bg-yellow-500", text: "Dry: <30%" }, + { dot: "bg-blue-500", text: "Humid: >60%" }, + ], + }, + ].map(({ icon: Ic, label, items }) => ( +
+

+ + {label} +

+
    + {items.map(({ dot, text }) => ( +
  • + + {text} +
  • + ))} +
+
+ ))}
- {/* Status Footer */} + {/* Status footer */}
-
-
-
-
- 24/7 Monitoring Active + + + {history.length} readings loaded +
- {currentData && ( + {currentData?.receivedAt && ( Last update: {new Date(currentData.receivedAt).toLocaleTimeString()} @@ -403,4 +532,4 @@ function RealtimeGraphs() { ); } -export default RealtimeGraphs; \ No newline at end of file +export default RealtimeGraphs; From a63f89a2dd6e2cf00e5e386919f07c45bfcd3b96 Mon Sep 17 00:00:00 2001 From: Kavindi487 Date: Mon, 16 Mar 2026 12:37:22 +0530 Subject: [PATCH 2/9] updated --- .../PatientPortal/RealtimeGraphs.jsx | 513 +++++------------- 1 file changed, 142 insertions(+), 371 deletions(-) diff --git a/src/components/PatientPortal/RealtimeGraphs.jsx b/src/components/PatientPortal/RealtimeGraphs.jsx index 9c7d3c6..cc87ef7 100644 --- a/src/components/PatientPortal/RealtimeGraphs.jsx +++ b/src/components/PatientPortal/RealtimeGraphs.jsx @@ -3,39 +3,17 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer, ReferenceLine } from "recharts"; -import { - Activity, Thermometer, Heart, Droplet, - Wind, Waves, Wifi, WifiOff, RefreshCw -} from "lucide-react"; - -// ─── URL helper ─────────────────────────────────────────────────────────────── -// Works for localhost AND hosted deployments automatically. -// Set VITE_IOT_URL in your .env for local dev. -// In production (Azure / Vercel / etc.) set the same env var to your hosted gateway URL. -const BACKEND_URL = (import.meta.env.VITE_IOT_URL || "").replace(/\/$/, ""); +import { Activity, Thermometer, Heart, Droplet, Wind, Waves, RefreshCw, WifiOff } from "lucide-react"; -// ─── Token helper — reads from whatever key your auth stores it ─────────────── -const getToken = () => - localStorage.getItem("token") || - localStorage.getItem("authToken") || - sessionStorage.getItem("token") || - ""; +const BACKEND_URL = (import.meta.env.VITE_IOT_URL || "http://localhost:8088").replace(/\/$/, ""); -// ─── Authenticated fetch wrapper ───────────────────────────────────────────── -const authFetch = (url, options = {}) => { - const token = getToken(); - return fetch(url, { - ...options, - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(options.headers || {}), - }, - }); -}; +// Gets patientId stored during login: localStorage.setItem("patientId", data.patientId) +const getPatientId = () => + localStorage.getItem("patientId") || + localStorage.getItem("userId") || + "1"; // fallback for demo -// ─── Metric Card ───────────────────────────────────────────────────────────── -function MetricCard({ icon: Icon, label, value, unit, status, statusColor, pulse }) { +function MetricCard({ icon: Icon, label, value, unit, status, statusColor, stale }) { return (
@@ -44,17 +22,18 @@ function MetricCard({ icon: Icon, label, value, unit, status, statusColor, pulse
{label} + {stale && ( + + cached + + )}
- - {value} + + {value ?? 0} {unit}
@@ -69,7 +48,6 @@ function MetricCard({ icon: Icon, label, value, unit, status, statusColor, pulse ); } -// ─── Graph Card ─────────────────────────────────────────────────────────────── function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) { return (
@@ -80,16 +58,14 @@ function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) {

{label}

{data.length > 0 && ( - - {data.length} readings - + {data.length} pts )}
{data.length === 0 ? (
- Waiting for sensor data… + No data yet — waiting for sensor readings…
) : ( @@ -97,40 +73,21 @@ function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) { - new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - } - stroke="#9ca3af" - style={{ fontSize: "11px" }} + tickFormatter={(v) => new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + stroke="#9ca3af" style={{ fontSize: "11px" }} /> new Date(v).toLocaleString()} formatter={(value) => [`${value} ${unit}`, label]} /> {refLines?.map((r) => ( - + ))} - + )} @@ -139,369 +96,186 @@ function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) { ); } -// ─── Connection badge ───────────────────────────────────────────────────────── -function ConnectionBadge({ mode }) { - if (mode === "sse") - return ( - - - Live stream - - ); - if (mode === "polling") - return ( - - - Polling 5s - - ); - return ( - - - Offline - - ); -} - -// ─── Main component ─────────────────────────────────────────────────────────── -function RealtimeGraphs() { - const [history, setHistory] = useState([]); - const [currentData, setCurrent] = useState(null); - const [loading, setLoading] = useState(true); - const [connMode, setConnMode] = useState("polling"); // "sse" | "polling" | "offline" - const [pulse, setPulse] = useState(false); - const [error, setError] = useState(null); - - const readerRef = useRef(null); +export default function RealtimeGraphs() { + const [history, setHistory] = useState([]); + const [current, setCurrent] = useState(null); + const [loading, setLoading] = useState(true); + const [isStale, setIsStale] = useState(false); // true = showing cached DB data + const [lastUpdate, setLastUpdate] = useState(null); + const [error, setError] = useState(null); const pollerRef = useRef(null); - // ── Append one new reading to history (keep last 100 points) ────────────── - const appendReading = useCallback((reading) => { - setHistory((prev) => { - const next = [...prev, reading]; - return next.slice(-100); - }); - setCurrent(reading); - setPulse(true); - setTimeout(() => setPulse(false), 600); - }, []); + const patientId = getPatientId(); - // ── Fetch history (REST) — used as initial load + polling fallback ───────── - const fetchHistory = useCallback(async () => { + const fetchData = useCallback(async () => { try { - const res = await authFetch( - `${BACKEND_URL}/api/sensordata/latest/50` + const res = await fetch( + `${BACKEND_URL}/api/sensordata/patient/${patientId}/latest/50` ); - if (res.status === 401) { - setError("Session expired — please log in again."); - setConnMode("offline"); - return; - } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); const sorted = Array.isArray(data) ? [...data].reverse() : []; - setHistory(sorted.slice(-100)); - if (sorted.length > 0) setCurrent(sorted[sorted.length - 1]); + + if (sorted.length > 0) { + setHistory(sorted); + const latest = sorted[sorted.length - 1]; + setCurrent(latest); + setLastUpdate(new Date(latest.receivedAt)); + + // Check if data is stale (older than 30 seconds = sensor not sending live) + const ageSeconds = (Date.now() - new Date(latest.receivedAt).getTime()) / 1000; + setIsStale(ageSeconds > 30); + } setError(null); } catch (e) { console.error("Fetch error:", e); - setConnMode("offline"); - setError("Cannot reach server. Check your connection."); - } - }, []); - - // ── SSE stream (real-time push from backend) ─────────────────────────────── - const startSseStream = useCallback(async () => { - const token = getToken(); - if (!token) return false; // no token — fall back to polling - - try { - const res = await fetch(`${BACKEND_URL}/api/sensordata/stream`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!res.ok || !res.body) return false; - - setConnMode("sse"); - const reader = res.body.getReader(); - readerRef.current = reader; - const decoder = new TextDecoder(); - - // Read stream in a loop - (async () => { - let buffer = ""; - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - // SSE messages are separated by double newlines - const parts = buffer.split("\n\n"); - buffer = parts.pop() || ""; - - for (const part of parts) { - const dataLine = part.split("\n").find((l) => l.startsWith("data:")); - if (!dataLine) continue; - const jsonStr = dataLine.slice(5).trim(); - if (!jsonStr || jsonStr === "ping") continue; - try { - const reading = JSON.parse(jsonStr); - appendReading(reading); - } catch (_) {} - } - } - } catch (_) { - // Stream broke — fall back to polling - } - setConnMode("polling"); - startPolling(); - })(); - - return true; - } catch (_) { - return false; + setError("Cannot reach IoT service — showing last known data"); + // Don't clear existing data — keep showing whatever we have + } finally { + setLoading(false); } - }, [appendReading]); - - // ── Polling fallback (every 5s) ──────────────────────────────────────────── - const startPolling = useCallback(() => { - if (pollerRef.current) clearInterval(pollerRef.current); - setConnMode("polling"); - pollerRef.current = setInterval(fetchHistory, 5000); - }, [fetchHistory]); + }, [patientId]); - // ── Bootstrap ───────────────────────────────────────────────────────────── useEffect(() => { - let cancelled = false; - - (async () => { - await fetchHistory(); - if (cancelled) return; - setLoading(false); - - const gotStream = await startSseStream(); - if (!gotStream && !cancelled) { - startPolling(); - } - })(); - - return () => { - cancelled = true; - readerRef.current?.cancel(); - clearInterval(pollerRef.current); - }; - }, [fetchHistory, startSseStream, startPolling]); + fetchData(); + pollerRef.current = setInterval(fetchData, 5000); // poll every 5s + return () => clearInterval(pollerRef.current); + }, [fetchData]); - // ── Status helpers ───────────────────────────────────────────────────────── - const hrStatus = (bpm) => { - if (!bpm || bpm === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (bpm < 60) return { text: "Low", color: "bg-yellow-100 text-yellow-700" }; - if (bpm > 100) return { text: "High", color: "bg-red-100 text-red-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + const hrStatus = (v) => { + if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; + if (v < 60) return { text: "Low", color: "bg-yellow-100 text-yellow-700" }; + if (v > 100) return { text: "High", color: "bg-red-100 text-red-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; const spo2Status = (v) => { if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; if (v < 95) return { text: "Low", color: "bg-orange-100 text-orange-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; - const tempStatus = (t) => { - if (!t || t === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (t < 36.1) return { text: "Low", color: "bg-blue-100 text-blue-700" }; - if (t > 37.2) return { text: "High", color: "bg-red-100 text-red-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + const tempStatus = (v) => { + if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; + if (v < 36.1) return { text: "Low", color: "bg-blue-100 text-blue-700" }; + if (v > 37.2) return { text: "High", color: "bg-red-100 text-red-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; - const hr = hrStatus(currentData?.avgBpm); - const sp = spo2Status(currentData?.spo2); - const bt = tempStatus(currentData?.waterTempC); - - // ── Loading screen ──────────────────────────────────────────────────────── - if (loading) { - return ( -
-
-
-

- Loading patient monitoring… -

-
+ if (loading) return ( +
+
+
+

Loading patient monitoring…

- ); - } +
+ ); + + const hr = hrStatus(current?.avgBpm); + const sp = spo2Status(current?.spo2); + const bt = tempStatus(current?.waterTempC); return (
- {/* Error banner */} + {/* Stale data banner */} + {isStale && !error && ( +
+ + Sensor not sending live data — showing last recorded readings from database + {lastUpdate && Last: {lastUpdate.toLocaleTimeString()}} +
+ )} + + {/* Error banner — but still shows data */} {error && (
{error} + {history.length > 0 && Showing {history.length} cached readings}
)} - {/* Patient Vital Signs */} -
-
+ {/* No data at all */} + {!loading && history.length === 0 && ( +
+ +

No sensor data found for this patient

+

Make sure the ESP32 is powered on and connected to WiFi

+
+ )} + + {/* Vital Signs */} +
+
- +
-

Patient Vital Signs

+

Patient Vital Signs

+ {isStale && Cached data}
- - - + + +
{/* Environmental */} -
-
+
+
- +
-

Environmental Conditions

+

Environmental Conditions

- - + +
- {/* Real-time graphs */} -
-
+ {/* Graphs */} +
+
- +
-

Real-Time Monitoring

+

Real-Time Monitoring

- - + +
- +
{/* Reference ranges */} -
-

- - Health Reference Ranges +
+

+ Health Reference Ranges

-
+
{[ - { - icon: Heart, label: "Heart Rate (BPM)", - items: [ - { dot: "bg-teal-500", text: "Normal: 60–100" }, - { dot: "bg-yellow-500", text: "Low: <60" }, - { dot: "bg-red-500", text: "High: >100" }, - ], - }, - { - icon: Wind, label: "SpO2 (%)", - items: [ - { dot: "bg-teal-500", text: "Normal: 95–100%" }, - { dot: "bg-orange-500", text: "Low: 90–94%" }, - { dot: "bg-red-500", text: "Critical: <90%" }, - ], - }, - { - icon: Waves, label: "Body Temperature (°C)", - items: [ - { dot: "bg-teal-500", text: "Normal: 36.1–37.2" }, - { dot: "bg-blue-500", text: "Low: <36.1" }, - { dot: "bg-red-500", text: "High: >37.2" }, - ], - }, - { - icon: Droplet, label: "Room Humidity (%)", - items: [ - { dot: "bg-teal-500", text: "Optimal: 30–60%" }, - { dot: "bg-yellow-500", text: "Dry: <30%" }, - { dot: "bg-blue-500", text: "Humid: >60%" }, - ], - }, + { icon: Heart, label: "Heart Rate (BPM)", items: [{ dot: "bg-teal-500", text: "Normal: 60–100" }, { dot: "bg-yellow-500", text: "Low: <60" }, { dot: "bg-red-500", text: "High: >100" }] }, + { icon: Wind, label: "SpO2 (%)", items: [{ dot: "bg-teal-500", text: "Normal: 95–100%" }, { dot: "bg-orange-500", text: "Low: 90–94%" }, { dot: "bg-red-500", text: "Critical: <90%" }] }, + { icon: Waves, label: "Body Temperature (°C)", items: [{ dot: "bg-teal-500", text: "Normal: 36.1–37.2" }, { dot: "bg-blue-500", text: "Low: <36.1" }, { dot: "bg-red-500", text: "High: >37.2" }] }, + { icon: Droplet, label: "Room Humidity (%)", items: [{ dot: "bg-teal-500", text: "Optimal: 30–60%" }, { dot: "bg-yellow-500", text: "Dry: <30%" }, { dot: "bg-blue-500", text: "Humid: >60%" }] }, ].map(({ icon: Ic, label, items }) => (

- - {label} + {label}

    {items.map(({ dot, text }) => (
  • - - {text} + {text}
  • ))}
@@ -512,17 +286,16 @@ function RealtimeGraphs() { {/* Status footer */}
-
+
- - - {history.length} readings loaded + + + {isStale ? "Showing cached data" : "Live — polling every 5s"} + {history.length} readings loaded
- {currentData?.receivedAt && ( - - Last update: {new Date(currentData.receivedAt).toLocaleTimeString()} - + {lastUpdate && ( + Last reading: {lastUpdate.toLocaleTimeString()} )}
@@ -531,5 +304,3 @@ function RealtimeGraphs() {
); } - -export default RealtimeGraphs; From 4a9de0b3cde0fb76bd62c1cffcb84f0f428388e0 Mon Sep 17 00:00:00 2001 From: Kavindi487 Date: Tue, 17 Mar 2026 10:07:56 +0530 Subject: [PATCH 3/9] updated --- .env.development | 2 +- .../PatientPortal/RealtimeGraphs.jsx | 258 ++++++++++-------- 2 files changed, 144 insertions(+), 116 deletions(-) diff --git a/.env.development b/.env.development index 4a390ef..301f0fe 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ VITE_API_URL=http://localhost:8088 VITE_WS_URL=http://localhost:8080 VITE_CHATBOT_URL=http://localhost:8088 -VITE_IOT_URL=http://localhost:8088 \ No newline at end of file +VITE_IOT_URL=http://localhost:8082 \ No newline at end of file diff --git a/src/components/PatientPortal/RealtimeGraphs.jsx b/src/components/PatientPortal/RealtimeGraphs.jsx index cc87ef7..82aef68 100644 --- a/src/components/PatientPortal/RealtimeGraphs.jsx +++ b/src/components/PatientPortal/RealtimeGraphs.jsx @@ -5,13 +5,8 @@ import { } from "recharts"; import { Activity, Thermometer, Heart, Droplet, Wind, Waves, RefreshCw, WifiOff } from "lucide-react"; -const BACKEND_URL = (import.meta.env.VITE_IOT_URL || "http://localhost:8088").replace(/\/$/, ""); - -// Gets patientId stored during login: localStorage.setItem("patientId", data.patientId) -const getPatientId = () => - localStorage.getItem("patientId") || - localStorage.getItem("userId") || - "1"; // fallback for demo +// Set VITE_IOT_URL=http://localhost:8082 in your .env file +const BACKEND_URL = (import.meta.env.VITE_IOT_URL || "http://localhost:8082").replace(/\/$/, ""); function MetricCard({ icon: Icon, label, value, unit, status, statusColor, stale }) { return ( @@ -22,11 +17,7 @@ function MetricCard({ icon: Icon, label, value, unit, status, statusColor, stale
{label} - {stale && ( - - cached - - )} + {stale && cached}
@@ -37,11 +28,7 @@ function MetricCard({ icon: Icon, label, value, unit, status, statusColor, stale {unit}
- {status && ( - - {status} - - )} + {status && {status}}

@@ -57,9 +44,7 @@ function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) {

{label}

- {data.length > 0 && ( - {data.length} pts - )} + {data.length > 0 && {data.length} pts}
@@ -73,21 +58,33 @@ function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) { new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - stroke="#9ca3af" style={{ fontSize: "11px" }} + tickFormatter={(v) => new Date(v + "Z").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + stroke="#9ca3af" + style={{ fontSize: "11px" }} /> new Date(v).toLocaleString()} + labelFormatter={(v) => new Date(v + "Z").toLocaleString()} formatter={(value) => [`${value} ${unit}`, label]} /> {refLines?.map((r) => ( - + ))} - + )} @@ -97,79 +94,101 @@ function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) { } export default function RealtimeGraphs() { - const [history, setHistory] = useState([]); - const [current, setCurrent] = useState(null); - const [loading, setLoading] = useState(true); - const [isStale, setIsStale] = useState(false); // true = showing cached DB data + const [history, setHistory] = useState([]); + const [current, setCurrent] = useState(null); + const [loading, setLoading] = useState(true); + const [isStale, setIsStale] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const pollerRef = useRef(null); - const patientId = getPatientId(); - const fetchData = useCallback(async () => { try { - const res = await fetch( - `${BACKEND_URL}/api/sensordata/patient/${patientId}/latest/50` - ); + const url = `${BACKEND_URL}/api/sensordata/latest/50`; + console.log("Fetching IoT data from:", url); + + const res = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" } + }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + const text = await res.text(); + console.error("Backend error:", text); + throw new Error(`HTTP ${res.status}`); + } const data = await res.json(); - const sorted = Array.isArray(data) ? [...data].reverse() : []; - if (sorted.length > 0) { - setHistory(sorted); - const latest = sorted[sorted.length - 1]; + if (Array.isArray(data) && data.length > 0) { + // FIX: Backend returns DESC (newest first). + // - current = data[0] (most recent reading) + // - chart = [...data].reverse() so x-axis goes oldest → newest + const latest = data[0]; + const chartData = [...data].reverse(); + + setHistory(chartData); setCurrent(latest); - setLastUpdate(new Date(latest.receivedAt)); - // Check if data is stale (older than 30 seconds = sensor not sending live) - const ageSeconds = (Date.now() - new Date(latest.receivedAt).getTime()) / 1000; + // FIX: Append "Z" so JS parses LocalDateTime as UTC, not local time. + // Without this, timezone differences cause false "stale" warnings. + const receivedAtUTC = new Date(latest.receivedAt + "Z"); + setLastUpdate(receivedAtUTC); + + const ageSeconds = (Date.now() - receivedAtUTC.getTime()) / 1000; setIsStale(ageSeconds > 30); + } else { + setHistory([]); + setCurrent(null); + setLastUpdate(null); + setIsStale(false); } + setError(null); } catch (e) { console.error("Fetch error:", e); setError("Cannot reach IoT service — showing last known data"); - // Don't clear existing data — keep showing whatever we have } finally { setLoading(false); } - }, [patientId]); + }, []); useEffect(() => { fetchData(); - pollerRef.current = setInterval(fetchData, 5000); // poll every 5s + pollerRef.current = setInterval(fetchData, 5000); return () => clearInterval(pollerRef.current); }, [fetchData]); const hrStatus = (v) => { if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (v < 60) return { text: "Low", color: "bg-yellow-100 text-yellow-700" }; - if (v > 100) return { text: "High", color: "bg-red-100 text-red-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + if (v < 60) return { text: "Low", color: "bg-yellow-100 text-yellow-700" }; + if (v > 100) return { text: "High", color: "bg-red-100 text-red-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; + const spo2Status = (v) => { if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (v < 95) return { text: "Low", color: "bg-orange-100 text-orange-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + if (v < 95) return { text: "Low", color: "bg-orange-100 text-orange-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; + const tempStatus = (v) => { if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (v < 36.1) return { text: "Low", color: "bg-blue-100 text-blue-700" }; - if (v > 37.2) return { text: "High", color: "bg-red-100 text-red-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + if (v < 36.1) return { text: "Low", color: "bg-blue-100 text-blue-700" }; + if (v > 37.2) return { text: "High", color: "bg-red-100 text-red-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; - if (loading) return ( -
-
-
-

Loading patient monitoring…

+ if (loading) { + return ( +
+
+
+

Loading patient monitoring…

+
-
- ); + ); + } const hr = hrStatus(current?.avgBpm); const sp = spo2Status(current?.spo2); @@ -179,50 +198,64 @@ export default function RealtimeGraphs() {
- {/* Stale data banner */} {isStale && !error && (
Sensor not sending live data — showing last recorded readings from database - {lastUpdate && Last: {lastUpdate.toLocaleTimeString()}} + {lastUpdate && ( + Last: {lastUpdate.toLocaleTimeString()} + )}
)} - {/* Error banner — but still shows data */} {error && (
{error} - {history.length > 0 && Showing {history.length} cached readings} + {history.length > 0 && ( + Showing {history.length} cached readings + )}
)} - {/* No data at all */} {!loading && history.length === 0 && (
-

No sensor data found for this patient

+

No sensor data found

Make sure the ESP32 is powered on and connected to WiFi

)} - {/* Vital Signs */} + {/* Patient Vital Signs */}

Patient Vital Signs

- {isStale && Cached data} + {isStale && ( + + Cached data + + )}
- - - + + +
- {/* Environmental */} + {/* Environmental Conditions */}
@@ -231,12 +264,18 @@ export default function RealtimeGraphs() {

Environmental Conditions

- - + +
- {/* Graphs */} + {/* Real-Time Charts */}
@@ -245,46 +284,33 @@ export default function RealtimeGraphs() {

Real-Time Monitoring

- - + +
- +
- {/* Reference ranges */} -
-

- Health Reference Ranges -

-
- {[ - { icon: Heart, label: "Heart Rate (BPM)", items: [{ dot: "bg-teal-500", text: "Normal: 60–100" }, { dot: "bg-yellow-500", text: "Low: <60" }, { dot: "bg-red-500", text: "High: >100" }] }, - { icon: Wind, label: "SpO2 (%)", items: [{ dot: "bg-teal-500", text: "Normal: 95–100%" }, { dot: "bg-orange-500", text: "Low: 90–94%" }, { dot: "bg-red-500", text: "Critical: <90%" }] }, - { icon: Waves, label: "Body Temperature (°C)", items: [{ dot: "bg-teal-500", text: "Normal: 36.1–37.2" }, { dot: "bg-blue-500", text: "Low: <36.1" }, { dot: "bg-red-500", text: "High: >37.2" }] }, - { icon: Droplet, label: "Room Humidity (%)", items: [{ dot: "bg-teal-500", text: "Optimal: 30–60%" }, { dot: "bg-yellow-500", text: "Dry: <30%" }, { dot: "bg-blue-500", text: "Humid: >60%" }] }, - ].map(({ icon: Ic, label, items }) => ( -
-

- {label} -

-
    - {items.map(({ dot, text }) => ( -
  • - {text} -
  • - ))} -
-
- ))} -
-
- - {/* Status footer */} + {/* Status bar */}
@@ -295,7 +321,9 @@ export default function RealtimeGraphs() { {history.length} readings loaded
{lastUpdate && ( - Last reading: {lastUpdate.toLocaleTimeString()} + + Last reading: {lastUpdate.toLocaleTimeString()} + )}
@@ -303,4 +331,4 @@ export default function RealtimeGraphs() {
); -} +} \ No newline at end of file From 88e29fb01fbc7974b44926e00b40afe5de2f28a7 Mon Sep 17 00:00:00 2001 From: Kavindi487 Date: Wed, 18 Mar 2026 23:45:49 +0530 Subject: [PATCH 4/9] updated --- .env.development | 4 +- .../PatientPortal/RealtimeGraphs.jsx | 189 +++++------------- 2 files changed, 53 insertions(+), 140 deletions(-) diff --git a/.env.development b/.env.development index 301f0fe..f6862db 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ VITE_API_URL=http://localhost:8088 -VITE_WS_URL=http://localhost:8080 +VITE_WS_URL=http://localhost:8088 VITE_CHATBOT_URL=http://localhost:8088 -VITE_IOT_URL=http://localhost:8082 \ No newline at end of file +VITE_IOT_URL=http://localhost:8088 \ No newline at end of file diff --git a/src/components/PatientPortal/RealtimeGraphs.jsx b/src/components/PatientPortal/RealtimeGraphs.jsx index 82aef68..84ff622 100644 --- a/src/components/PatientPortal/RealtimeGraphs.jsx +++ b/src/components/PatientPortal/RealtimeGraphs.jsx @@ -1,17 +1,22 @@ -import { useEffect, useState, useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Activity, Heart, RefreshCw, Waves, WifiOff, Wind } from "lucide-react"; import { - LineChart, Line, XAxis, YAxis, Tooltip, - CartesianGrid, ResponsiveContainer, ReferenceLine + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, } from "recharts"; -import { Activity, Thermometer, Heart, Droplet, Wind, Waves, RefreshCw, WifiOff } from "lucide-react"; -// Set VITE_IOT_URL=http://localhost:8082 in your .env file -const BACKEND_URL = (import.meta.env.VITE_IOT_URL || "http://localhost:8082").replace(/\/$/, ""); +const BACKEND_URL = import.meta.env.VITE_IOT_URL || "http://localhost:8088"; function MetricCard({ icon: Icon, label, value, unit, status, statusColor, stale }) { return ( -
-
+
+
@@ -93,7 +98,7 @@ function GraphCard({ data, dataKey, label, icon: Icon, unit, refLines }) { ); } -export default function RealtimeGraphs() { +export default function RealtimeGraphs({ patientId: propPatientId }) { const [history, setHistory] = useState([]); const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(true); @@ -102,36 +107,39 @@ export default function RealtimeGraphs() { const [error, setError] = useState(null); const pollerRef = useRef(null); + const patientId = propPatientId || localStorage.getItem("patientId"); + const fetchData = useCallback(async () => { + if (!patientId) { + setError("No patient ID found"); + setLoading(false); + return; + } + try { - const url = `${BACKEND_URL}/api/sensordata/latest/50`; + const url = `${BACKEND_URL}/api/sensordata/patient/${patientId}/latest/50`; console.log("Fetching IoT data from:", url); const res = await fetch(url, { method: "GET", - headers: { "Content-Type": "application/json" } + headers: { + "Content-Type": "application/json", + }, }); if (!res.ok) { - const text = await res.text(); - console.error("Backend error:", text); throw new Error(`HTTP ${res.status}`); } const data = await res.json(); if (Array.isArray(data) && data.length > 0) { - // FIX: Backend returns DESC (newest first). - // - current = data[0] (most recent reading) - // - chart = [...data].reverse() so x-axis goes oldest → newest const latest = data[0]; const chartData = [...data].reverse(); setHistory(chartData); setCurrent(latest); - // FIX: Append "Z" so JS parses LocalDateTime as UTC, not local time. - // Without this, timezone differences cause false "stale" warnings. const receivedAtUTC = new Date(latest.receivedAt + "Z"); setLastUpdate(receivedAtUTC); @@ -147,11 +155,11 @@ export default function RealtimeGraphs() { setError(null); } catch (e) { console.error("Fetch error:", e); - setError("Cannot reach IoT service — showing last known data"); + setError("Cannot reach IoT service — showing last known patient data"); } finally { setLoading(false); } - }, []); + }, [patientId]); useEffect(() => { fetchData(); @@ -161,33 +169,26 @@ export default function RealtimeGraphs() { const hrStatus = (v) => { if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (v < 60) return { text: "Low", color: "bg-yellow-100 text-yellow-700" }; - if (v > 100) return { text: "High", color: "bg-red-100 text-red-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + if (v < 60) return { text: "Low", color: "bg-yellow-100 text-yellow-700" }; + if (v > 100) return { text: "High", color: "bg-red-100 text-red-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; const spo2Status = (v) => { if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (v < 95) return { text: "Low", color: "bg-orange-100 text-orange-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + if (v < 95) return { text: "Low", color: "bg-orange-100 text-orange-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; const tempStatus = (v) => { if (!v || v === 0) return { text: "No Data", color: "bg-gray-100 text-gray-600" }; - if (v < 36.1) return { text: "Low", color: "bg-blue-100 text-blue-700" }; - if (v > 37.2) return { text: "High", color: "bg-red-100 text-red-700" }; - return { text: "Normal", color: "bg-teal-100 text-teal-700" }; + if (v < 36.1) return { text: "Low", color: "bg-blue-100 text-blue-700" }; + if (v > 37.2) return { text: "High", color: "bg-red-100 text-red-700" }; + return { text: "Normal", color: "bg-teal-100 text-teal-700" }; }; if (loading) { - return ( -
-
-
-

Loading patient monitoring…

-
-
- ); + return
Loading patient monitoring…
; } const hr = hrStatus(current?.avgBpm); @@ -196,14 +197,15 @@ export default function RealtimeGraphs() { return (
-
- +
{isStale && !error && (
Sensor not sending live data — showing last recorded readings from database {lastUpdate && ( - Last: {lastUpdate.toLocaleTimeString()} + + Last: {lastUpdate.toLocaleTimeString()} + )}
)} @@ -221,113 +223,24 @@ export default function RealtimeGraphs() { {!loading && history.length === 0 && (
-

No sensor data found

-

Make sure the ESP32 is powered on and connected to WiFi

+

No sensor data found for this patient

+

Make sure the device is assigned and sending data

)} - {/* Patient Vital Signs */} -
-
-
- -
-

Patient Vital Signs

- {isStale && ( - - Cached data - - )} -
-
- - - -
-
- - {/* Environmental Conditions */}
-
-
- -
-

Environmental Conditions

-
-
- - +
+ + +
- {/* Real-Time Charts */} -
-
-
- -
-

Real-Time Monitoring

-
-
- - -
- -
-
-
- - {/* Status bar */} -
-
-
- - - {isStale ? "Showing cached data" : "Live — polling every 5s"} - - {history.length} readings loaded -
- {lastUpdate && ( - - Last reading: {lastUpdate.toLocaleTimeString()} - - )} -
+
+ + +
-
); From 0db9ee399ff1c81ae87599afff128d450bbeb150 Mon Sep 17 00:00:00 2001 From: Kavindi487 Date: Fri, 20 Mar 2026 16:00:13 +0530 Subject: [PATCH 5/9] updated --- src/pages/AdminPages/IotDevices.jsx | 190 +++++- src/pages/AdminPages/PatientManagement.jsx | 702 +++++++++++++-------- 2 files changed, 633 insertions(+), 259 deletions(-) diff --git a/src/pages/AdminPages/IotDevices.jsx b/src/pages/AdminPages/IotDevices.jsx index 8710554..f18902c 100644 --- a/src/pages/AdminPages/IotDevices.jsx +++ b/src/pages/AdminPages/IotDevices.jsx @@ -1,14 +1,192 @@ -import React from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { API_BASE_URL } from "../../api"; function IotDevices() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [error, setError] = useState(""); + + const fetchDevices = async () => { + try { + setLoading(true); + setError(""); + + const token = localStorage.getItem("token"); + + const response = await fetch(`${API_BASE_URL}/api/sensordata/devices`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + const text = await response.text(); + let data = []; + + try { + data = text ? JSON.parse(text) : []; + } catch { + data = []; + } + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch devices"); + } + + setDevices(Array.isArray(data) ? data : []); + } catch (err) { + console.error(err); + setError(err.message || "Unable to load IoT devices"); + setDevices([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchDevices(); + }, []); + + const filteredDevices = useMemo(() => { + return devices.filter((device) => { + const q = search.toLowerCase(); + + return ( + String(device.deviceId || "").toLowerCase().includes(q) || + String(device.status || "").toLowerCase().includes(q) || + String(device.userId || "").toLowerCase().includes(q) + ); + }); + }, [devices, search]); + + const getStatusStyle = (status) => { + switch ((status || "").toUpperCase()) { + case "ACTIVE": + return "bg-green-100 text-green-700"; + case "INACTIVE": + return "bg-red-100 text-red-700"; + case "UNASSIGNED": + return "bg-yellow-100 text-yellow-700"; + default: + return "bg-gray-100 text-gray-700"; + } + }; + + const maskToken = (token) => { + if (!token) return "-"; + if (token.length <= 6) return token; + return `${token.slice(0, 3)}***${token.slice(-3)}`; + }; + + const totalDevices = devices.length; + const activeDevices = devices.filter( + (d) => String(d.status || "").toUpperCase() === "ACTIVE" + ).length; + const assignedDevices = devices.filter((d) => d.userId !== null && d.userId !== undefined).length; + return (
-

IoT Devices

-

- IoT device list and live status will appear here. -

+
+
+

IoT Devices

+

+ Monitor device assignment and status +

+
+ + +
+ +
+
+

Total Devices

+

{totalDevices}

+
+ +
+

Active Devices

+

{activeDevices}

+
+ +
+

Assigned Devices

+

{assignedDevices}

+
+
+ + setSearch(e.target.value)} + className="w-full mb-4 px-4 py-2 bg-gray-100 rounded-full text-sm outline-none" + /> + + {error && ( +
+ {error} +
+ )} + + {loading &&

Loading devices...

} + + {!loading && filteredDevices.length === 0 && ( +

No IoT devices found

+ )} + + {!loading && filteredDevices.length > 0 && ( +
+ {filteredDevices.map((device) => ( +
+
+

+ {device.deviceId || "-"} +

+

+ Token: {maskToken(device.deviceToken)} +

+
+ +
+
+

Patient ID

+

+ {device.userId ?? "Unassigned"} +

+
+ +
+

Status

+ + {device.status || "UNKNOWN"} + +
+ +
+

Device Row ID

+

{device.id}

+
+
+
+ ))} +
+ )}
); } -export default IotDevices \ No newline at end of file +export default IotDevices; \ No newline at end of file diff --git a/src/pages/AdminPages/PatientManagement.jsx b/src/pages/AdminPages/PatientManagement.jsx index bc62df2..5582d94 100644 --- a/src/pages/AdminPages/PatientManagement.jsx +++ b/src/pages/AdminPages/PatientManagement.jsx @@ -7,62 +7,72 @@ function PatientManagement() { const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [showModal, setShowModal] = useState(false); - const [formData, setFormData] = useState({ - name: "", - dateOfBirth: "", - address: "", - email: "", - nicNo: "", - gender: "", - contactNo: "", - guardiansName: "", - guardiansContactNo: "", - username: "", - password: "", - bloodType: "", - city: "", - district: "", - dispostalCodet: "", - guardianRelationship: "", - guardianEmail: "", - medicalConditions: "", - allergies: "", - currentMedications: "", - pastSurgeries: "", - emergencyNotes: "", - }); - const [error, setError] = useState(""); - const [submitting, setSubmitting] = useState(false); - - const [showEditModal, setShowEditModal] = useState(false); - const [editPatientId, setEditPatientId] = useState(null); - const [editFormData, setEditFormData] = useState({ - name: "", - dateOfBirth: "", - address: "", - email: "", - nicNo: "", - gender: "", - contactNo: "", - guardiansName: "", - guardiansContactNo: "", - username: "", - password: "", - bloodType: "", - city: "", - district: "", - dispostalCodet: "", - guardianRelationship: "", - guardianEmail: "", - medicalConditions: "", - allergies: "", - currentMedications: "", - pastSurgeries: "", - emergencyNotes: "", - }); -//new - const [showViewModal, setShowViewModal] = useState(false); - const [selectedPatient, setSelectedPatient] = useState(null); + + const [formData, setFormData] = useState({ + name: "", + dateOfBirth: "", + address: "", + email: "", + nicNo: "", + gender: "", + contactNo: "", + guardiansName: "", + guardiansContactNo: "", + username: "", + password: "", + bloodType: "", + city: "", + district: "", + dispostalCodet: "", + guardianRelationship: "", + guardianEmail: "", + medicalConditions: "", + allergies: "", + currentMedications: "", + pastSurgeries: "", + emergencyNotes: "", + deviceId: "", // NEW + }); + + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const [showEditModal, setShowEditModal] = useState(false); + const [editPatientId, setEditPatientId] = useState(null); + const [editFormData, setEditFormData] = useState({ + name: "", + dateOfBirth: "", + address: "", + email: "", + nicNo: "", + gender: "", + contactNo: "", + guardiansName: "", + guardiansContactNo: "", + username: "", + password: "", + bloodType: "", + city: "", + district: "", + dispostalCodet: "", + guardianRelationship: "", + guardianEmail: "", + medicalConditions: "", + allergies: "", + currentMedications: "", + pastSurgeries: "", + emergencyNotes: "", + }); + + const [showViewModal, setShowViewModal] = useState(false); + const [selectedPatient, setSelectedPatient] = useState(null); + + // NEW: assign device modal state + const [showAssignModal, setShowAssignModal] = useState(false); + const [assignPatientId, setAssignPatientId] = useState(null); + const [deviceId, setDeviceId] = useState(""); + const [assigning, setAssigning] = useState(false); + const [assignError, setAssignError] = useState(""); useEffect(() => { async function load() { @@ -78,25 +88,34 @@ function PatientManagement() { load(); }, []); - // search bar + const reloadPatients = async () => { + try { + const data = await fetchPatient(); + setPatient(data || []); + } catch (err) { + console.error(err); + } + }; + const filteredPatients = patients.filter((pt) => pt.name?.toLowerCase().includes(search.toLowerCase()) ); const handleDelete = async (PatientId) => { - if (!window.confirm("Reject this Patient?")) return; - - try { - await deletePatient(PatientId); - - setPatient((prev) => - prev.filter((pt) => pt.Id !== PatientId) - ); - - alert("Patient rejected successfully"); - } catch (error) { - console.error(error); - alert("Failed to reject Patient")} + if (!window.confirm("Reject this Patient?")) return; + + try { + await deletePatient(PatientId); + + setPatient((prev) => + prev.filter((pt) => (pt.id || pt.Id) !== PatientId) + ); + + alert("Patient rejected successfully"); + } catch (error) { + console.error(error); + alert("Failed to reject Patient"); + } }; const handleInputChange = (e) => { @@ -115,11 +134,26 @@ function PatientManagement() { nicNo: patient.nicNo || "", gender: patient.gender || "", contactNo: patient.contactNo || "", + guardiansName: patient.guardiansName || "", + guardiansContactNo: patient.guardiansContactNo || "", + username: patient.username || "", + password: patient.password || "", + bloodType: patient.bloodType || "", + city: patient.city || "", + district: patient.district || "", + dispostalCodet: patient.dispostalCodet || "", + guardianRelationship: patient.guardianRelationship || "", + guardianEmail: patient.guardianEmail || "", + medicalConditions: patient.medicalConditions || "", + allergies: patient.allergies || "", + currentMedications: patient.currentMedications || "", + pastSurgeries: patient.pastSurgeries || "", + emergencyNotes: patient.emergencyNotes || "", }); setShowEditModal(true); - }; - //new + }; + const handleView = (patient) => { setSelectedPatient(patient); setShowViewModal(true); @@ -130,117 +164,197 @@ function PatientManagement() { setEditFormData((prev) => ({ ...prev, [name]: value })); }; + // NEW + const openAssignModal = (patient) => { + setAssignPatientId(patient.id || patient.Id); + setDeviceId(""); + setAssignError(""); + setShowAssignModal(true); + }; + + // NEW + const handleAssignDevice = async (e) => { + e.preventDefault(); + setAssignError(""); + + if (!deviceId.trim()) { + setAssignError("Please enter a device ID"); + return; + } + + try { + setAssigning(true); + const token = localStorage.getItem("token"); + + const response = await fetch(`${API_BASE_URL}/api/sensordata/devices/assign`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + deviceId: deviceId.trim(), + userId: assignPatientId, + }), + }); + + const text = await response.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { message: text }; + } + + if (!response.ok) { + setAssignError(data.message || "Failed to assign device"); + return; + } + + alert("Device assigned successfully"); + setShowAssignModal(false); + setDeviceId(""); + setAssignError(""); + } catch (err) { + console.error(err); + setAssignError("Unable to connect to server"); + } finally { + setAssigning(false); + } + }; + const handleCreatePatient = async (e) => { - e.preventDefault(); - setError(""); - - if ( - !formData.name || - !formData.email || - !formData.username || - !formData.password || - !formData.address || - !formData.dateOfBirth || - !formData.allergies || - !formData.nicNo || - !formData.gender || - !formData.contactNo || - !formData.bloodType || - !formData.city || - !formData.currentMedications || - !formData.dispostalCodet || - !formData.district || - !formData.guardianEmail || - !formData.guardianRelationship || - !formData.guardiansContactNo || - !formData.guardiansName || - !formData.medicalConditions - ) { - setError("Please fill in all required fields"); - return; - } - - setSubmitting(true); + e.preventDefault(); + setError(""); + + if ( + !formData.name || + !formData.email || + !formData.username || + !formData.password || + !formData.address || + !formData.dateOfBirth || + !formData.allergies || + !formData.nicNo || + !formData.gender || + !formData.contactNo || + !formData.bloodType || + !formData.city || + !formData.currentMedications || + !formData.dispostalCodet || + !formData.district || + !formData.guardianEmail || + !formData.guardianRelationship || + !formData.guardiansContactNo || + !formData.guardiansName || + !formData.medicalConditions + ) { + setError("Please fill in all required fields"); + return; + } + + setSubmitting(true); + try { + const token = localStorage.getItem("token"); + const response = await fetch(`${API_BASE_URL}/api/admin/patient/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(formData), + }); + + const text = await response.text(); + let createdPatient = null; + try { - const token = localStorage.getItem("token"); - const response = await fetch(`${API_BASE_URL}/api/admin/patient/create`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}`, - }, - body: JSON.stringify(formData), - }); - - if (!response.ok) { - const text = await response.text(); - setError(text || "Failed to create Patient"); - setSubmitting(false); - return; - } - - // Reset form and close modal - setFormData({ - name: "", - dateOfBirth: "", - address: "", - email: "", - nicNo: "", - gender: "", - contactNo: "", - guardiansName: "", - guardiansContactNo: "", - username: "", - password: "", - bloodType: "", - city: "", - district: "", - dispostalCodet: "", - guardianRelationship: "", - guardianEmail: "", - medicalConditions: "", - allergies: "", - currentMedications: "", - pastSurgeries: "", - emergencyNotes: "", - createdAt: "", - updatedAt: "", - }); - setShowModal(false); - setError(""); + createdPatient = text ? JSON.parse(text) : null; + } catch { + createdPatient = null; + } - const data = await fetchPatient(); - setPatient(response.data || []); - } catch (e) { - setError("Unable to connect to server. Please try again."); - } finally { - setSubmitting(false); - - } - }; - const handleUpdatePatient = async (e) => { - e.preventDefault(); - + if (!response.ok) { + setError(text || "Failed to create Patient"); + setSubmitting(false); + return; + } + + // OPTIONAL auto assignment if deviceId is entered during create + if (formData.deviceId && createdPatient?.id) { try { - await updatePatient(editPatientId, editFormData); - - const data = await fetchPatient(); - setPatient(data || []); - setShowEditModal(false); - - alert("Patient updated successfully"); - } catch (err) { - console.error(err); - alert(err.message || "Failed to update Patient"); + await fetch(`${API_BASE_URL}/api/sensordata/devices/assign`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + deviceId: formData.deviceId.trim(), + userId: createdPatient.id, + }), + }); + } catch (assignErr) { + console.error("Device assignment failed after create:", assignErr); } - }; + } + setFormData({ + name: "", + dateOfBirth: "", + address: "", + email: "", + nicNo: "", + gender: "", + contactNo: "", + guardiansName: "", + guardiansContactNo: "", + username: "", + password: "", + bloodType: "", + city: "", + district: "", + dispostalCodet: "", + guardianRelationship: "", + guardianEmail: "", + medicalConditions: "", + allergies: "", + currentMedications: "", + pastSurgeries: "", + emergencyNotes: "", + deviceId: "", + }); + + setShowModal(false); + setError(""); + await reloadPatients(); + } catch (e) { + console.error(e); + setError("Unable to connect to server. Please try again."); + } finally { + setSubmitting(false); + } + }; + + const handleUpdatePatient = async (e) => { + e.preventDefault(); + + try { + await updatePatient(editPatientId, editFormData); + await reloadPatients(); + setShowEditModal(false); + alert("Patient updated successfully"); + } catch (err) { + console.error(err); + alert(err.message || "Failed to update Patient"); + } + }; return (

Patients

- + + +
@@ -305,64 +421,133 @@ function PatientManagement() {
{/* View Patient Modal */} - {showViewModal && selectedPatient && ( -
- -
- - {/* Header */} -
-

- Patient Profile -

- + {showViewModal && selectedPatient && ( +
+
+
+

Patient Profile

+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + +
+
- {/* Scrollable Body */} -
-
- - - - - - - - {/* Right column */} - - - - - - - - {/* Guardian Details */} - - - - +
+ +
+
+
+ )} + + {/* Assign Device Modal */} + {showAssignModal && ( +
+
+
+

Assign Device

+ +
+ +
+ {assignError && ( +
+ {assignError} +
+ )} +
+
+ + +
+ +
+ + setDeviceId(e.target.value)} + placeholder="ESP32_001" + className="w-full h-10 px-3 rounded-lg border focus:border-blue-500 outline-none" + required + />
- {/* Footer */} -
+
+
- -
+
- )} +
+ )} {/* Create Patient Modal */} {showModal && ( @@ -376,7 +561,7 @@ function PatientManagement() { setShowModal(false); setError(""); setFormData({ - name: "", + name: "", dateOfBirth: "", address: "", email: "", @@ -398,6 +583,7 @@ function PatientManagement() { currentMedications: "", pastSurgeries: "", emergencyNotes: "", + deviceId: "", }); }} className="text-gray-500 hover:text-gray-700 text-2xl" @@ -600,10 +786,10 @@ function PatientManagement() { className="w-full h-10 px-3 rounded-lg border focus:border-blue-500 outline-none" />
- +
+ {/* NEW optional device field */} +
+ + +
@@ -744,12 +944,14 @@ function PatientManagement() { currentMedications: "", pastSurgeries: "", emergencyNotes: "", + deviceId: "", }); }} className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50" > Cancel + - -
- {/* View Patient Modal */} {showViewModal && selectedPatient && (
-
+

Patient Profile

@@ -471,121 +311,14 @@ function PatientManagement() {
)} - {/* Assign Device Modal */} - {showAssignModal && ( -
-
-
-

Assign Device

- -
- - - {assignError && ( -
- {assignError} -
- )} - -
-
- - -
- -
- - setDeviceId(e.target.value)} - placeholder="ESP32_001" - className="w-full h-10 px-3 rounded-lg border focus:border-blue-500 outline-none" - required - /> -
-
- -
- - -
- -
-
- )} - - {/* Create Patient Modal */} {showModal && (
-
+

Create New Patient