From 2834564727a6f67e1e0b9f82e2042bd924e42ddb Mon Sep 17 00:00:00 2001 From: Anurup R Krishnan Date: Wed, 11 Mar 2026 02:00:29 +0530 Subject: [PATCH 01/53] Wire telemed waiting room, messaging contacts, and record/timeline actions --- .../app/patient/appointments/page.tsx | 53 ++++++++++++---- .../components/features/ward/ward-map.tsx | 11 ++++ .../doctor/records/doctor-medical-records.tsx | 27 +++++--- .../appointments/appointment-booking.tsx | 8 ++- .../patient/dashboard/patient-timeline.tsx | 61 +++++++++++-------- .../patient/records/medical-records.tsx | 9 ++- .../components/telemedicine/ChatWindow.tsx | 2 +- .../telemedicine/MessagingInterface.tsx | 29 ++++++--- .../components/telemedicine/waiting-room.tsx | 53 +++++++++++++++- securemed-frontend/services/telemedicine.ts | 6 ++ 10 files changed, 199 insertions(+), 60 deletions(-) diff --git a/securemed-frontend/app/patient/appointments/page.tsx b/securemed-frontend/app/patient/appointments/page.tsx index d8b222a..de99fb5 100644 --- a/securemed-frontend/app/patient/appointments/page.tsx +++ b/securemed-frontend/app/patient/appointments/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, Suspense } from 'react'; +import React, { useState, useEffect, Suspense, useCallback } from 'react'; import { useSearchParams } from 'next/navigation'; import { useAuth } from '@/context/auth-context'; import { Button } from '@/components/ui/button'; @@ -10,9 +10,12 @@ import MyAppointments from '@/components/portals/patient/appointments/my-appoint import WaitingRoom from '@/components/telemedicine/waiting-room'; import VideoRoom from '@/components/telemedicine/video-room'; import { appointmentService } from '@/services/appointments'; +import { videoService } from '@/services/telemedicine'; +import { useToast } from '@/hooks/use-toast'; function AppointmentsContent() { const { isAuthenticated } = useAuth(); + const { toast } = useToast(); const searchParams = useSearchParams(); const initialDoctorId = searchParams.get('doctorId') || undefined; const autoJoin = searchParams.get('join') === '1'; @@ -42,8 +45,14 @@ function AppointmentsContent() { return dateA.getTime() - dateB.getTime(); }); if (upcoming.length > 0) { - setNextAppointment(upcoming[0]); - setActiveRoomId(`room-${upcoming[0].id}`); + const next = upcoming[0]; + setNextAppointment(next); + try { + const room = await videoService.getActiveRoom(next.patient); + setActiveRoomId(room?.room_id || ''); + } catch { + setActiveRoomId(''); + } } } catch (e) { console.error('Failed to fetch upcoming appointment:', e); @@ -53,12 +62,37 @@ function AppointmentsContent() { fetchNextAppointment(); }, [isAuthenticated]); + const handleJoinTelemed = useCallback(async () => { + if (!nextAppointment) return; + let roomId = activeRoomId; + if (!roomId) { + try { + const room = await videoService.getActiveRoom(nextAppointment.patient); + roomId = room?.room_id || ''; + setActiveRoomId(roomId); + } catch { + roomId = ''; + } + } + + if (!roomId) { + toast({ + title: 'Waiting room not ready', + description: 'Your doctor has not started the room yet. Please try again shortly.', + variant: 'destructive' + }); + return; + } + + setShowTelemed(true); + setTelemedStatus('waiting'); + }, [activeRoomId, nextAppointment, toast]); + useEffect(() => { - if (autoJoin && nextAppointment && activeRoomId) { - setShowTelemed(true); - setTelemedStatus('waiting'); + if (autoJoin && nextAppointment) { + handleJoinTelemed(); } - }, [autoJoin, nextAppointment, activeRoomId]); + }, [autoJoin, nextAppointment, handleJoinTelemed]); if (showTelemed) { return ( @@ -98,10 +132,7 @@ function AppointmentsContent() { )} + {selectedRoom?.patientId && ( + + )} diff --git a/securemed-frontend/components/portals/doctor/records/doctor-medical-records.tsx b/securemed-frontend/components/portals/doctor/records/doctor-medical-records.tsx index bf2c29f..2407a9c 100644 --- a/securemed-frontend/components/portals/doctor/records/doctor-medical-records.tsx +++ b/securemed-frontend/components/portals/doctor/records/doctor-medical-records.tsx @@ -26,6 +26,8 @@ interface DoctorMedicalRecordsProps { export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecordsProps) { const searchParams = useSearchParams(); const { toast } = useToast(); + const urlPatientId = searchParams?.get('patient_id') || ''; + const resolvedPatientId = patientId || urlPatientId || ''; const [medicalRecords, setMedicalRecords] = useState([]); const [prescriptions, setPrescriptions] = useState([]); const [loading, setLoading] = useState(true); @@ -36,7 +38,7 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords const [createOpen, setCreateOpen] = useState(false); const [creating, setCreating] = useState(false); const [newRecord, setNewRecord] = useState({ - patient_id: patientId || '', + patient_id: resolvedPatientId, record_type: '', record_date: new Date().toISOString().slice(0, 10), diagnosis: '', @@ -56,6 +58,12 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords } }, [searchParams]); + useEffect(() => { + if (resolvedPatientId) { + setNewRecord((prev) => ({ ...prev, patient_id: resolvedPatientId })); + } + }, [resolvedPatientId]); + // Debounce search input useEffect(() => { const timer = setTimeout(() => { @@ -71,8 +79,8 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords if (search?.trim()) { params.search = search.trim(); } - if (patientId?.trim()) { - params.patient_id = patientId.trim(); + if (resolvedPatientId?.trim()) { + params.patient_id = resolvedPatientId.trim(); } const [recordsResponse, prescriptionsResponse] = await Promise.all([ @@ -96,11 +104,11 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords } finally { setLoading(false); } - }, [patientId]); + }, [resolvedPatientId]); useEffect(() => { fetchRecords(debouncedSearch); - }, [patientId, debouncedSearch, fetchRecords]); + }, [resolvedPatientId, debouncedSearch, fetchRecords]); const filteredRecords = medicalRecords.filter(record => { const matchesFilter = filterType === 'all' || record.record_type === filterType; @@ -152,7 +160,7 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords

Medical Records

- {patientId ? 'Patient medical history and records' : 'Manage your practice and patients'} + {resolvedPatientId ? 'Patient medical history and records' : 'Manage your practice and patients'}

- diff --git a/securemed-frontend/components/portals/patient/dashboard/patient-timeline.tsx b/securemed-frontend/components/portals/patient/dashboard/patient-timeline.tsx index b8f733d..d26b647 100644 --- a/securemed-frontend/components/portals/patient/dashboard/patient-timeline.tsx +++ b/securemed-frontend/components/portals/patient/dashboard/patient-timeline.tsx @@ -146,14 +146,25 @@ export default function PatientTimeline({ patientId, className }: EnhancedPatien }; const parseEventId = (id: string) => { - const parts = id.split('-').filter(Boolean); - if (parts.length < 2) { - return { type: id, rawId: '' }; + if (!id) { + return { type: '', rawId: '' }; } - return { - type: parts.slice(0, -1).join('-'), - rawId: parts[parts.length - 1] - }; + if (/^\d+$/.test(id)) { + return { type: 'id', rawId: id }; + } + if (id.includes('_')) { + const parts = id.split('_').filter(Boolean); + if (parts.length >= 2) { + return { type: parts[0], rawId: parts.slice(1).join('_') }; + } + } + if (id.includes('-')) { + const parts = id.split('-').filter(Boolean); + if (parts.length >= 2) { + return { type: parts[0], rawId: parts.slice(1).join('-') }; + } + } + return { type: id, rawId: '' }; }; const openLabAttachment = async (labResultId: number) => { @@ -182,41 +193,37 @@ export default function PatientTimeline({ patientId, className }: EnhancedPatien const handleViewDetails = async (event: TimelineEvent) => { if (!event?.id) return; const parsed = parseEventId(event.id); - const eventType = (event.type || parsed.type || '').toLowerCase(); + const normalizedType = (event.type || parsed.type || '').toLowerCase(); const rawId = parsed.rawId; + const category = event.category?.toLowerCase(); + const isAppointment = category === 'appointment' || ['appointment', 'appt'].includes(normalizedType); + const isRecord = ['record', 'rec'].includes(normalizedType); + const isLab = category === 'lab' || ['lab', 'lab-result', 'lab_result'].includes(normalizedType); + const isInvoice = category === 'billing' || ['invoice', 'inv'].includes(normalizedType); + const isMedication = category === 'medication' || ['pharmacy', 'prescription', 'rx'].includes(normalizedType); - if (eventType === 'appointment') { + if (isAppointment) { router.push(`/patient/appointments?appointmentId=${rawId}`); return; } - if (eventType === 'record') { + if (isRecord) { router.push(`/patient/records?recordId=${rawId}`); return; } - if (eventType === 'lab-result') { - const idNumber = Number(rawId); - if (Number.isFinite(idNumber) && idNumber > 0) { - if (event.details?.has_attachment) { - await openLabAttachment(idNumber); - return; - } - toast({ - title: 'No attachment found', - description: 'This lab result does not include a report file.', - variant: 'destructive' - }); + if (isLab) { + const attachmentId = Number(event.details?.lab_result_id || event.details?.result_id || rawId); + if (Number.isFinite(attachmentId) && attachmentId > 0) { + await openLabAttachment(attachmentId); + return; } - return; - } - if (eventType === 'lab-order') { router.push('/patient/records'); return; } - if (eventType === 'invoice') { + if (isInvoice) { router.push(`/patient/billing?invoiceId=${rawId}`); return; } - if (eventType === 'pharmacy' || eventType === 'prescription') { + if (isMedication) { router.push('/patient/records'); return; } diff --git a/securemed-frontend/components/portals/patient/records/medical-records.tsx b/securemed-frontend/components/portals/patient/records/medical-records.tsx index bf58896..fed1834 100644 --- a/securemed-frontend/components/portals/patient/records/medical-records.tsx +++ b/securemed-frontend/components/portals/patient/records/medical-records.tsx @@ -343,11 +343,16 @@ export default function MedicalRecords({ patientId }: MedicalRecordsProps) {

{record.record_type_display || 'Medical Record'}

{record.diagnosis}

- {record.file && ( + {(record.file_url || record.file) && ( + )} )} - {selectedRoom?.patientId && ( - + router.push(`/doctor/patients/${selectedRoom.patientId}`); + }}> + View Patient Profile + + + )} diff --git a/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx b/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx index 3c84664..f7a8d09 100644 --- a/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx +++ b/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx @@ -363,21 +363,6 @@ export default function ForceGraph({ data, highlightTrace, isActive }: ForceGrap return (
-
-
- -

Contact Network Graph

-
-
- {Object.entries(NODE_COLORS).map(([type, color]) => ( - - - {type} - - ))} -
-
-
-

Contact Graph

-

- {activeTrace - ? 'Focused on the selected trace only, so the contact path and timing are easier to read.' - : 'Showing the full hospital contact network. Select a trace above to isolate its chain.'} -

+
+
+

Contact Network Graph

+

+ {activeTrace + ? 'Focused on the selected trace only, so the contact path and timing are easier to read.' + : 'Showing the full hospital contact network. Select a trace above to isolate its chain.'} +

+
+
+ {Object.entries(NODE_COLORS).map(([type, color]) => ( + + + {type} + + ))} +
+
diff --git a/securemed-frontend/components/portals/patient/dashboard/anatomy-education-card.tsx b/securemed-frontend/components/portals/patient/dashboard/anatomy-education-card.tsx index acc6610..a635719 100644 --- a/securemed-frontend/components/portals/patient/dashboard/anatomy-education-card.tsx +++ b/securemed-frontend/components/portals/patient/dashboard/anatomy-education-card.tsx @@ -14,7 +14,19 @@ import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; -import { AnatomySelectionPayload, REGION_LOOKUP } from '@/components/features/anatomy/region-map'; +import { + AnatomySelectionPayload, + REGION_LOOKUP, + deriveSymptomsFromRegions, +} from '@/components/features/anatomy/region-map'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { AnatomyRegionExplainer, ConditionCatalogItem, @@ -61,6 +73,10 @@ export default function AnatomyEducationCard() { const [matching, setMatching] = useState(false); const [error, setError] = useState(null); const [step, setStep] = useState<1 | 2 | 3 | 4>(1); + const [wizardOpen, setWizardOpen] = useState(false); + const [wizardRegion, setWizardRegion] = useState('chest'); + const [wizardPain, setWizardPain] = useState(6); + const [wizardConcern, setWizardConcern] = useState('pain'); // Derived: which mode does the canvas operate in? const canvasMode = activeConditionId && visualization ? 'condition' : 'selection'; @@ -177,6 +193,15 @@ export default function AnatomyEducationCard() { setStep(1); }; + const applySelection = (regions: string[], intensity: Record) => { + const selectedSymptoms = deriveSymptomsFromRegions(regions); + setSelection({ selectedRegions: regions, selectedSymptoms, intensityByRegion: intensity }); + setActiveRegion(regions[regions.length - 1] || null); + setActiveConditionId(''); + setConditionMatches([]); + setStep(2); + }; + const handleNext = () => { if (!canGoNext) return; setStep((prev) => (prev < 4 ? ((prev + 1) as 2 | 3 | 4) : prev)); @@ -210,13 +235,33 @@ export default function AnatomyEducationCard() { ); return ( - + <> + {/* ── Header ─────────────────────────────────────────────── */}

Anatomy Education & Condition Visualization

+
+ + + +
{visualization ? ( @@ -624,6 +669,71 @@ export default function AnatomyEducationCard() {
{error &&

{error}

} - + + + + + + Guided Symptom Wizard + + Pick a region and pain level to generate educational suggestions. Not a diagnosis. + + +
+
+ + +
+
+ + +
+
+ + setWizardPain(value?.[0] ?? 5)} + /> +
Selected: {wizardPain}/10
+
+
+ + + + +
+
+ ); } From e6185f572e537337d637404a004c3e0385d73b42 Mon Sep 17 00:00:00 2001 From: Anurup R Krishnan Date: Wed, 11 Mar 2026 14:01:09 +0530 Subject: [PATCH 46/53] Improve ward map dialog and expand contact graph --- .../components/features/ward/ward-map.tsx | 54 +++++++++++-------- .../admin/infection-tracking/force-graph.tsx | 8 ++- .../infection-tracking-portal.tsx | 12 ++++- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/securemed-frontend/components/features/ward/ward-map.tsx b/securemed-frontend/components/features/ward/ward-map.tsx index b0e3d6a..1d9d880 100644 --- a/securemed-frontend/components/features/ward/ward-map.tsx +++ b/securemed-frontend/components/features/ward/ward-map.tsx @@ -200,33 +200,43 @@ export function WardMap({ filter = 'all', onRoomsChange }: WardMapProps) { {selectedRoom ? ( -
-
- Status - {selectedRoom.isOccupied ? 'Occupied' : 'Empty'} -
- {selectedRoom.isOccupied && ( - <> +
+
+

Room Status

+
+
+ Status + {selectedRoom.isOccupied ? 'Occupied' : 'Empty'} +
- Patient - {selectedRoom.patientName} + Acuity + {selectedRoom.acuity || 'N/A'}
- {selectedRoom.patientDisplayId && ( +
+ Isolation + {selectedRoom.isIsolation ? 'Yes' : 'No'} +
+
+
+ {selectedRoom.isOccupied ? ( +
+

Patient

+
- Patient ID - {selectedRoom.patientDisplayId} + Name + {selectedRoom.patientName}
- )} - + {selectedRoom.patientDisplayId && ( +
+ Patient ID + {selectedRoom.patientDisplayId} +
+ )} +
+
+ ) : ( +
No patient assigned to this room.
)} -
- Acuity - {selectedRoom.acuity || 'N/A'} -
-
- Isolation - {selectedRoom.isIsolation ? 'Yes' : 'No'} -
) : (
No room selected.
diff --git a/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx b/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx index f7a8d09..a3aca66 100644 --- a/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx +++ b/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx @@ -28,6 +28,7 @@ type ForceGraphProps = { data: GraphVisualization; highlightTrace: InfectionTrace | null; isActive: boolean; + focusTrace?: boolean; }; const GRAPH_HEIGHT = 500; @@ -105,7 +106,7 @@ function filterGraphToTrace(data: GraphVisualization, trace: InfectionTrace | nu }; } -export default function ForceGraph({ data, highlightTrace, isActive }: ForceGraphProps) { +export default function ForceGraph({ data, highlightTrace, isActive, focusTrace = false }: ForceGraphProps) { const canvasRef = useRef(null); const workerRef = useRef(null); const pendingWorkerIdRef = useRef(0); @@ -125,7 +126,10 @@ export default function ForceGraph({ data, highlightTrace, isActive }: ForceGrap const linksRef = useRef([]); const gridRef = useRef>(new Map()); const nodeByIdRef = useRef>(new Map()); - const displayData = useMemo(() => filterGraphToTrace(data, highlightTrace), [data, highlightTrace]); + const displayData = useMemo( + () => (focusTrace ? filterGraphToTrace(data, highlightTrace) : data), + [data, highlightTrace, focusTrace] + ); useEffect(() => { latestHighlightRef.current = highlightTrace; diff --git a/securemed-frontend/components/portals/admin/infection-tracking/infection-tracking-portal.tsx b/securemed-frontend/components/portals/admin/infection-tracking/infection-tracking-portal.tsx index 92858b3..0cd67ac 100644 --- a/securemed-frontend/components/portals/admin/infection-tracking/infection-tracking-portal.tsx +++ b/securemed-frontend/components/portals/admin/infection-tracking/infection-tracking-portal.tsx @@ -43,6 +43,7 @@ export default function InfectionTrackingPortal({ const [error, setError] = useState(null); const [selectedTrace, setSelectedTrace] = useState(null); const [refreshToken, setRefreshToken] = useState(0); + const [focusTrace, setFocusTrace] = useState(false); const activeTrace = selectedTrace ?? traces?.[0] ?? null; const refresh = useCallback(() => setRefreshToken((token) => token + 1), []); @@ -73,7 +74,7 @@ export default function InfectionTrackingPortal({ try { const [graphRes, tracesRes, statsRes] = await Promise.all([ - infectionTrackingService.getGraphVisualization(180, { signal: controller.signal }), + infectionTrackingService.getGraphVisualization(320, { signal: controller.signal }), infectionTrackingService.getTraces({ signal: controller.signal }), infectionTrackingService.getGraphStats({ signal: controller.signal }), ]); @@ -202,6 +203,13 @@ export default function InfectionTrackingPortal({

+ {Object.entries(NODE_COLORS).map(([type, color]) => ( @@ -211,7 +219,7 @@ export default function InfectionTrackingPortal({
- + )} From c1d90a05633b1075f9c7a77fb5cefaf7a784800b Mon Sep 17 00:00:00 2001 From: Anurup R Krishnan Date: Wed, 11 Mar 2026 14:05:19 +0530 Subject: [PATCH 47/53] Redesign contact network graph canvas --- .../admin/infection-tracking/force-graph.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx b/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx index a3aca66..52a1586 100644 --- a/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx +++ b/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx @@ -31,7 +31,7 @@ type ForceGraphProps = { focusTrace?: boolean; }; -const GRAPH_HEIGHT = 500; +const GRAPH_HEIGHT = 560; const HIT_CELL_SIZE = 56; const MAX_DPR = 1.5; @@ -365,8 +365,25 @@ export default function ForceGraph({ data, highlightTrace, isActive, focusTrace const hasNoGraphData = !Array.isArray(data?.nodes) || data.nodes.length === 0; + const nodeCount = displayData?.nodes?.length ?? 0; + const linkCount = displayData?.links?.length ?? 0; + return ( -
+
+