diff --git a/src/api/hospital.ts b/src/api/hospital.ts index e112094..9455d5d 100644 --- a/src/api/hospital.ts +++ b/src/api/hospital.ts @@ -73,14 +73,35 @@ export const hospitalApi = { /** * Get incoming operations/dispatches for the hospital. */ - getIncomingOperations: async (token: string, hospitalId?: string) => { - const url = hospitalId - ? `/v1/hospital/ops/incoming?hospitalId=${hospitalId}` - : '/v1/hospital/ops/incoming'; + getIncomingOperations: async (token: string, hospitalId?: string, status?: string) => { + const params = new URLSearchParams(); + if (hospitalId) params.set('hospitalId', hospitalId); + if (status) params.set('status', status); + const qs = params.toString(); + const url = `/v1/hospital/ops/incoming${qs ? '?' + qs : ''}`; return apiClient.get(url, { token }); }, admitPatient: async (patientId: string, departmentId: string, token: string) => { - return apiClient.post(`/v1/hospital/ops/incoming/${patientId}/admit`, { departmentId }, { token }); + return apiClient.patch(`/v1/hospital/ops/incoming/${patientId}/admit`, { departmentId, bedType: 'General' }, { token }); + }, + + /** + * Get all admitted patients for this hospital. + */ + getAdmissions: async (token: string, page?: number, limit?: number, status?: string) => { + const params = new URLSearchParams(); + if (page) params.set('page', String(page)); + if (limit) params.set('limit', String(limit)); + if (status) params.set('status', status); + const qs = params.toString(); + return apiClient.get(`/v1/hospital/ops/admissions${qs ? '?' + qs : ''}`, { token }); + }, + + /** + * Discharge a patient by admission ID. + */ + dischargePatient: async (admissionId: string, token: string) => { + return apiClient.patch(`/v1/hospital/ops/admissions/${admissionId}/discharge`, {}, { token }); }, }; diff --git a/src/config/navigation.ts b/src/config/navigation.ts index f023ffc..2453ac6 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -17,7 +17,8 @@ import { LayoutGrid, Video, FileText, - TrendingUp + TrendingUp, + BedDouble } from 'lucide-react'; export interface NavItem { @@ -96,6 +97,7 @@ export const NAVIGATION_CONFIG: NavItem[] = [ roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'], subItems: [ { id: 'hosp-ed', label: 'ED Monitor', icon: Monitor, path: '/hospital-console?tab=ED_MONITOR', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] }, + { id: 'hosp-admissions', label: 'Admissions', icon: BedDouble, path: '/hospital-console?tab=ADMISSIONS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] }, { id: 'hosp-bookings', label: 'Trip Management', icon: Activity, path: '/hospital-console?tab=BOOKINGS', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] }, { id: 'hosp-fleet', label: 'Fleet Visibility', icon: Truck, path: '/hospital-console?tab=FLEET', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] }, { id: 'hosp-telelink', label: 'TeleLink Hub', icon: Video, path: '/hospital-console?tab=TELELINK', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] }, diff --git a/src/index.css b/src/index.css index f142a0f..f7641b2 100644 --- a/src/index.css +++ b/src/index.css @@ -509,6 +509,9 @@ select, select option { background: var(--accent-cyan-soft); color: var(--accent-cyan); font-weight: 600; + border-left: 3px solid var(--accent-cyan); + border-radius: 0 8px 8px 0; + margin-left: 0; } .sidebar-nav-item.active::before { @@ -540,6 +543,10 @@ select, select option { background: var(--accent-cyan-soft); color: var(--accent-cyan); font-weight: 600; + border-left: 3px solid var(--accent-cyan); + border-radius: 0 6px 6px 0; + margin-left: 0; + padding-left: 45px; } .sidebar-sub-item.active::before { diff --git a/src/pages/HospitalConsole.tsx b/src/pages/HospitalConsole.tsx index 5c50b6c..3a55de9 100644 --- a/src/pages/HospitalConsole.tsx +++ b/src/pages/HospitalConsole.tsx @@ -13,6 +13,8 @@ import { Video, Hospital, ChevronDown, + ChevronRight, + BedDouble, } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { Card } from '../components/Common'; @@ -30,10 +32,13 @@ import { EPCRRecords } from './hospital/EPCRRecords'; import { PatientArchive } from './hospital/PatientArchive'; import { ReferralsSetup } from './hospital/ReferralsSetup'; import { HospitalAnalytics } from './hospital/HospitalAnalytics'; +import { AdmissionsBoard } from './hospital/AdmissionsBoard'; import './HospitalConsole.css'; type ConsoleModule = + | 'HUB' | 'ED_MONITOR' + | 'ADMISSIONS' | 'BOOKINGS' | 'FLEET' | 'TELELINK' @@ -60,7 +65,8 @@ export const HospitalConsole: React.FC = () => { const [selectedHospital, setSelectedHospital] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); - const activeModule = (searchParams.get('tab') as ConsoleModule) || 'ED_MONITOR'; + const activeTab = searchParams.get('tab') as ConsoleModule | null; + const activeModule = activeTab || 'HUB'; const setActiveModule = (key: ConsoleModule) => { setSearchParams({ tab: key }); }; @@ -528,6 +534,75 @@ export const HospitalConsole: React.FC = () => { // ── Module renderer ── const renderModule = () => { switch (activeModule) { + case 'HUB': + return ( +
+ {MODULE_CARDS.map(card => ( + setSearchParams({ tab: card.key })} + style={{ + background: '#fff', + border: '1px solid var(--card-border)', + borderRadius: '20px', + padding: '24px', + cursor: 'pointer', + position: 'relative', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + gap: '16px', + boxShadow: 'var(--shadow-sm)' + }} + > +
+ +
+
+

{card.label}

+

+ {card.description} +

+
+
+ Open Module +
+
+ + ))} +
+ ); + case 'ED_MONITOR': return ( { /> ); + case 'ADMISSIONS': + return ( + { + window.dispatchEvent(new CustomEvent('refresh_incoming')); + }} + /> + ); + case 'BOOKINGS': return ( { // Module card definitions with descriptions and color accents const MODULE_CARDS: { key: ConsoleModule; icon: any; label: string; description: string; accent: string; accentBg: string }[] = [ { key: 'ED_MONITOR', icon: Monitor, label: 'ED Monitor', description: 'Live triage stream, incoming patients & emergency broadcasts', accent: 'hsl(0, 84%, 60%)', accentBg: 'hsla(0, 84%, 60%, 0.08)' }, + { key: 'ADMISSIONS', icon: BedDouble, label: 'Admissions', description: 'Admitted patients, ward management & discharge workflow', accent: 'hsl(262, 83%, 58%)', accentBg: 'hsla(262, 83%, 58%, 0.08)' }, { key: 'BOOKINGS', icon: Activity, label: 'Trip Management', description: 'Ambulance bookings, dispatches & trip tracking', accent: 'hsl(199, 89%, 48%)', accentBg: 'hsla(199, 89%, 48%, 0.08)' }, { key: 'FLEET', icon: Truck, label: 'Fleet Visibility', description: 'Real-time fleet map, vehicle status & route monitoring', accent: 'hsl(262, 83%, 58%)', accentBg: 'hsla(262, 83%, 58%, 0.08)' }, { key: 'TELELINK', icon: Video, label: 'TeleLink Hub', description: 'Video consults, physician console & remote triage', accent: 'hsl(152, 69%, 40%)', accentBg: 'hsla(152, 69%, 40%, 0.08)' }, diff --git a/src/pages/hospital/AdmissionsBoard.tsx b/src/pages/hospital/AdmissionsBoard.tsx new file mode 100644 index 0000000..c7ce3de --- /dev/null +++ b/src/pages/hospital/AdmissionsBoard.tsx @@ -0,0 +1,469 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Search, + UserCheck, + Clock, + Building2, + AlertCircle, + CheckCircle2, + LogOut, + RefreshCw, + X, + ChevronRight, + Activity, + BedDouble, +} from 'lucide-react'; +import { hospitalApi } from '../../api/hospital'; + +interface AdmissionsBoardProps { + onRefresh?: () => void; +} + +export const AdmissionsBoard: React.FC = ({ onRefresh }) => { + const [admissions, setAdmissions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [dischargingId, setDischargingId] = useState(null); + const [confirmDischargeId, setConfirmDischargeId] = useState(null); + const [apiStats, setApiStats] = useState({ total: 0, admitted: 0, discharged: 0 }); + const [successMsg, setSuccessMsg] = useState(''); + + const loadAdmissions = async (filter = statusFilter) => { + setIsLoading(true); + try { + const token = localStorage.getItem('teleems_token') || ''; + if (!token) return; + const res = await hospitalApi.getAdmissions(token, 1, 50, filter); + console.log('[Admissions] API Response:', res); + + let items: any[] = []; + const data = res?.data || res; + + if (data?.items && Array.isArray(data.items)) { + items = data.items; + } else if (Array.isArray(data)) { + items = data; + } + + setAdmissions(items); + + if (data?.stats) { + setApiStats(data.stats); + } + } catch (err) { + console.error('Failed to fetch admissions:', err); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadAdmissions(); + const interval = setInterval(() => loadAdmissions(), 20000); + return () => clearInterval(interval); + }, [statusFilter]); + + const handleDischarge = async (admissionId: string) => { + setDischargingId(admissionId); + try { + const token = localStorage.getItem('teleems_token') || ''; + const res = await hospitalApi.dischargePatient(admissionId, token); + console.log('[Admissions] Discharge Response:', res); + + if (res?.error) { + alert('Discharge failed: ' + (res.error.message || 'Unknown error')); + } else { + setSuccessMsg('Patient discharged successfully'); + setTimeout(() => setSuccessMsg(''), 3000); + setConfirmDischargeId(null); + loadAdmissions(); + if (onRefresh) onRefresh(); + } + } catch (err: any) { + alert('Error discharging patient: ' + err.message); + } finally { + setDischargingId(null); + } + }; + + const stats = useMemo(() => ({ + total: apiStats.total, + admitted: apiStats.admitted, + discharged: apiStats.discharged, + }), [apiStats]); + + const filteredAdmissions = useMemo(() => { + return admissions.filter(admission => { + const patientName = admission.patient?.name || ''; + const deptName = admission.department?.name || ''; + const matchesSearch = + patientName.toLowerCase().includes(searchQuery.toLowerCase()) || + deptName.toLowerCase().includes(searchQuery.toLowerCase()) || + admission.patient_id?.toLowerCase().includes(searchQuery.toLowerCase()); + + // The API already filters by status if not 'ALL', + // but we keep this for consistency and safety. + const matchesStatus = statusFilter === 'ALL' || admission.status === statusFilter; + return matchesSearch && matchesStatus; + }); + }, [admissions, searchQuery, statusFilter]); + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '--'; + const d = new Date(dateStr); + return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }) + + ' ' + d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }); + }; + + const getTimeSince = (dateStr: string | null) => { + if (!dateStr) return '--'; + const diff = Date.now() - new Date(dateStr).getTime(); + const hours = Math.floor(diff / 3600000); + const mins = Math.floor((diff % 3600000) / 60000); + if (hours > 24) return Math.floor(hours / 24) + 'd ' + (hours % 24) + 'h'; + if (hours > 0) return hours + 'h ' + mins + 'm'; + return mins + 'm'; + }; + + const statuses = ['ALL', 'ADMITTED', 'DISCHARGED']; + + return ( + + {/* Success Toast */} + {successMsg && ( +
+ {successMsg} +
+ )} + + {/* Metrics Row */} +
+ {[ + { label: 'Total Records', count: stats.total, icon: , color: '#0ea5e9' }, + { label: 'Currently Admitted', count: stats.admitted, icon: , color: '#8b5cf6' }, + { label: 'Discharged', count: stats.discharged, icon: , color: '#10b981' }, + ].map((m, i) => ( +
+
+ {m.icon} +
+
+
{m.count}
+
{m.label}
+
+
+ ))} +
+ + {/* Header & Filters */} +
+
+
+ ADMISSIONS REGISTRY +
+

Ward Management

+
+ +
+
+ + setSearchQuery(e.target.value)} + style={{ fontWeight: 600 }} + /> +
+ +
+ {statuses.map(status => ( + + ))} +
+ + +
+
+ + {/* Content */} + {isLoading && admissions.length === 0 ? ( +
+
+

Loading admissions...

+
+ ) : filteredAdmissions.length === 0 ? ( +
+
+ +
+

No Admissions Found

+

+ {searchQuery || statusFilter !== 'ALL' ? 'No records match your current filters.' : 'No patients have been admitted yet.'} +

+
+ ) : ( +
+ + {filteredAdmissions.map((admission) => ( + + {/* Status indicator bar */} +
+ +
+ {/* Patient Info */} +
+
Patient
+
+ {admission.patient?.name || 'Unknown'} +
+
+ + {admission.patient?.triage_code || '--'} + + + {admission.patient?.age || '--'}Y • {admission.patient?.gender || '--'} + +
+ {admission.patient?.chief_complaint && ( +
+ {admission.patient.chief_complaint} +
+ )} +
+ + {/* Department Info */} +
+
Department
+
+ + + {admission.department?.name || '--'} + +
+
+ HOD: {admission.department?.headOfDepartment || '--'} +
+
+ + Beds: {admission.department?.occupiedBeds || 0}/{admission.department?.totalBedsCapacity || 0} + + {admission.bed_type && ( + + {admission.bed_type} + + )} +
+
+ + {/* Timeline */} +
+
Timeline
+
+ + + Admitted {formatDate(admission.admitted_at)} + +
+
+ Duration: {getTimeSince(admission.admitted_at)} +
+ {admission.discharged_at && ( +
+ + + Discharged {formatDate(admission.discharged_at)} + +
+ )} +
+ + {/* Actions */} +
+ + {admission.status} + + + {admission.status === 'ADMITTED' && ( + + )} +
+
+ + ))} + +
+ )} + + {/* Discharge Confirmation Modal */} + {createPortal( + + {confirmDischargeId && ( +
+ setConfirmDischargeId(null)} + style={{ position: 'absolute', inset: 0, background: 'rgba(15, 23, 42, 0.4)', backdropFilter: 'blur(2px)' }} + /> + +
+
+
+ +
+

Confirm Discharge

+
+ +
+ +
+ {(() => { + const adm = admissions.find(a => a.id === confirmDischargeId); + return ( +
+

+ You are about to discharge the following patient. This action cannot be undone. +

+
+
{adm?.patient?.name || 'Unknown'}
+
+ {adm?.department?.name || '--'} • Admitted {formatDate(adm?.admitted_at)} +
+
+
+ ); + })()} +
+ +
+ + +
+
+
+ )} +
, + document.body + )} +
+ ); +}; diff --git a/src/pages/hospital/EDMonitor.tsx b/src/pages/hospital/EDMonitor.tsx index ba7e84a..74af29d 100644 --- a/src/pages/hospital/EDMonitor.tsx +++ b/src/pages/hospital/EDMonitor.tsx @@ -85,7 +85,7 @@ export const EDMonitor: React.FC = ({ const stats = useMemo(() => { return { - total: incomingPatients.filter(p => p.status === 'IN_TRANSIT').length, + total: incomingPatients.filter(p => p.status === 'IN_TRANSIT' || p.status === 'PATIENT_LOADED').length, critical: incomingPatients.filter(p => (p.triage === 'RED' || p.triage === 'CRITICAL') && p.status !== 'HANDOFF_COMPLETE').length, onsite: incomingPatients.filter(p => p.status === 'ARRIVED').length, handoff: incomingPatients.filter(p => p.status === 'HANDOFF_COMPLETE').length, @@ -106,7 +106,7 @@ export const EDMonitor: React.FC = ({ }); }, [incomingPatients, searchQuery, statusFilter]); - const statuses = ['ALL', 'IN_TRANSIT', 'ARRIVED', 'HANDOFF_COMPLETE']; + const statuses = ['ALL', 'PATIENT_LOADED', 'IN_TRANSIT', 'ARRIVED', 'HANDOFF_COMPLETE']; const triageData = useMemo(() => { const counts: any = { RED: 0, ORANGE: 0, YELLOW: 0, GREEN: 0, WHITE: 0 };