feat: initialize hospital dashboard foundation with navigation config, global design tokens, and core operational pages
This commit is contained in:
@@ -73,14 +73,35 @@ export const hospitalApi = {
|
|||||||
/**
|
/**
|
||||||
* Get incoming operations/dispatches for the hospital.
|
* Get incoming operations/dispatches for the hospital.
|
||||||
*/
|
*/
|
||||||
getIncomingOperations: async (token: string, hospitalId?: string) => {
|
getIncomingOperations: async (token: string, hospitalId?: string, status?: string) => {
|
||||||
const url = hospitalId
|
const params = new URLSearchParams();
|
||||||
? `/v1/hospital/ops/incoming?hospitalId=${hospitalId}`
|
if (hospitalId) params.set('hospitalId', hospitalId);
|
||||||
: '/v1/hospital/ops/incoming';
|
if (status) params.set('status', status);
|
||||||
|
const qs = params.toString();
|
||||||
|
const url = `/v1/hospital/ops/incoming${qs ? '?' + qs : ''}`;
|
||||||
return apiClient.get(url, { token });
|
return apiClient.get(url, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
admitPatient: async (patientId: string, departmentId: string, token: string) => {
|
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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Video,
|
Video,
|
||||||
FileText,
|
FileText,
|
||||||
TrendingUp
|
TrendingUp,
|
||||||
|
BedDouble
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@@ -96,6 +97,7 @@ export const NAVIGATION_CONFIG: NavItem[] = [
|
|||||||
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'],
|
roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'],
|
||||||
subItems: [
|
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-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-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-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'] },
|
{ id: 'hosp-telelink', label: 'TeleLink Hub', icon: Video, path: '/hospital-console?tab=TELELINK', roles: ['CURESELECT_ADMIN', 'HOSPITAL_ADMIN', 'ED_DOCTOR', 'COORDINATOR', 'EMT'] },
|
||||||
|
|||||||
@@ -509,6 +509,9 @@ select, select option {
|
|||||||
background: var(--accent-cyan-soft);
|
background: var(--accent-cyan-soft);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
border-left: 3px solid var(--accent-cyan);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav-item.active::before {
|
.sidebar-nav-item.active::before {
|
||||||
@@ -540,6 +543,10 @@ select, select option {
|
|||||||
background: var(--accent-cyan-soft);
|
background: var(--accent-cyan-soft);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-weight: 600;
|
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 {
|
.sidebar-sub-item.active::before {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
Hospital,
|
Hospital,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
BedDouble,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card } from '../components/Common';
|
import { Card } from '../components/Common';
|
||||||
@@ -30,10 +32,13 @@ import { EPCRRecords } from './hospital/EPCRRecords';
|
|||||||
import { PatientArchive } from './hospital/PatientArchive';
|
import { PatientArchive } from './hospital/PatientArchive';
|
||||||
import { ReferralsSetup } from './hospital/ReferralsSetup';
|
import { ReferralsSetup } from './hospital/ReferralsSetup';
|
||||||
import { HospitalAnalytics } from './hospital/HospitalAnalytics';
|
import { HospitalAnalytics } from './hospital/HospitalAnalytics';
|
||||||
|
import { AdmissionsBoard } from './hospital/AdmissionsBoard';
|
||||||
import './HospitalConsole.css';
|
import './HospitalConsole.css';
|
||||||
|
|
||||||
type ConsoleModule =
|
type ConsoleModule =
|
||||||
|
| 'HUB'
|
||||||
| 'ED_MONITOR'
|
| 'ED_MONITOR'
|
||||||
|
| 'ADMISSIONS'
|
||||||
| 'BOOKINGS'
|
| 'BOOKINGS'
|
||||||
| 'FLEET'
|
| 'FLEET'
|
||||||
| 'TELELINK'
|
| 'TELELINK'
|
||||||
@@ -60,7 +65,8 @@ export const HospitalConsole: React.FC = () => {
|
|||||||
const [selectedHospital, setSelectedHospital] = useState<any>(null);
|
const [selectedHospital, setSelectedHospital] = useState<any>(null);
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
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) => {
|
const setActiveModule = (key: ConsoleModule) => {
|
||||||
setSearchParams({ tab: key });
|
setSearchParams({ tab: key });
|
||||||
};
|
};
|
||||||
@@ -528,6 +534,75 @@ export const HospitalConsole: React.FC = () => {
|
|||||||
// ── Module renderer ──
|
// ── Module renderer ──
|
||||||
const renderModule = () => {
|
const renderModule = () => {
|
||||||
switch (activeModule) {
|
switch (activeModule) {
|
||||||
|
case 'HUB':
|
||||||
|
return (
|
||||||
|
<div className="ops-hub-grid" style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||||
|
gap: '24px',
|
||||||
|
padding: '20px 0'
|
||||||
|
}}>
|
||||||
|
{MODULE_CARDS.map(card => (
|
||||||
|
<motion.div
|
||||||
|
key={card.key}
|
||||||
|
whileHover={{ y: -5, scale: 1.02 }}
|
||||||
|
onClick={() => 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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: card.accentBg,
|
||||||
|
color: card.accent,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<card.icon size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800 }}>{card.label}</h3>
|
||||||
|
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: card.accent
|
||||||
|
}}>
|
||||||
|
Open Module <ChevronRight size={14} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: '4px',
|
||||||
|
height: '100%',
|
||||||
|
background: card.accent
|
||||||
|
}} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case 'ED_MONITOR':
|
case 'ED_MONITOR':
|
||||||
return (
|
return (
|
||||||
<EDMonitor
|
<EDMonitor
|
||||||
@@ -543,6 +618,15 @@ export const HospitalConsole: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'ADMISSIONS':
|
||||||
|
return (
|
||||||
|
<AdmissionsBoard
|
||||||
|
onRefresh={() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('refresh_incoming'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'BOOKINGS':
|
case 'BOOKINGS':
|
||||||
return (
|
return (
|
||||||
<TripManagement
|
<TripManagement
|
||||||
@@ -608,6 +692,7 @@ export const HospitalConsole: React.FC = () => {
|
|||||||
// Module card definitions with descriptions and color accents
|
// Module card definitions with descriptions and color accents
|
||||||
const MODULE_CARDS: { key: ConsoleModule; icon: any; label: string; description: string; accent: string; accentBg: string }[] = [
|
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: '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: '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: '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)' },
|
{ 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)' },
|
||||||
|
|||||||
469
src/pages/hospital/AdmissionsBoard.tsx
Normal file
469
src/pages/hospital/AdmissionsBoard.tsx
Normal file
@@ -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<AdmissionsBoardProps> = ({ onRefresh }) => {
|
||||||
|
const [admissions, setAdmissions] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||||
|
const [dischargingId, setDischargingId] = useState<string | null>(null);
|
||||||
|
const [confirmDischargeId, setConfirmDischargeId] = useState<string | null>(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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="module-content"
|
||||||
|
style={{ background: 'var(--tactical-bg)', minHeight: '100%', padding: '24px' }}
|
||||||
|
>
|
||||||
|
{/* Success Toast */}
|
||||||
|
{successMsg && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 30, left: '50%', transform: 'translateX(-50%)', zIndex: 9999,
|
||||||
|
background: '#10b981', color: '#fff', padding: '12px 24px', borderRadius: '12px',
|
||||||
|
fontWeight: 800, fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
boxShadow: '0 8px 24px rgba(16, 185, 129, 0.3)',
|
||||||
|
}}>
|
||||||
|
<CheckCircle2 size={18} /> {successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metrics Row */}
|
||||||
|
<div className="metrics-row-modern">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Records', count: stats.total, icon: <Activity size={20} />, color: '#0ea5e9' },
|
||||||
|
{ label: 'Currently Admitted', count: stats.admitted, icon: <BedDouble size={20} />, color: '#8b5cf6' },
|
||||||
|
{ label: 'Discharged', count: stats.discharged, icon: <CheckCircle2 size={20} />, color: '#10b981' },
|
||||||
|
].map((m, i) => (
|
||||||
|
<div key={i} className="metric-card-premium">
|
||||||
|
<div className="metric-icon-wrap" style={{ color: m.color }}>
|
||||||
|
{m.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: '#0f172a', lineHeight: 1 }}>{m.count}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '4px' }}>{m.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header & Filters */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', marginTop: '8px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#8b5cf6', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.05em' }}>
|
||||||
|
<UserCheck size={14} /> ADMISSIONS REGISTRY
|
||||||
|
</div>
|
||||||
|
<h2 style={{ margin: '8px 0 0 0', fontSize: '1.5rem', fontWeight: 700, color: '#1e293b', letterSpacing: '-0.02em' }}>Ward Management</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<div className="search-mini" style={{ width: '280px', background: '#f1f5f9', border: '1px solid #e2e8f0' }}>
|
||||||
|
<Search size={16} color="#64748b" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search patient, department..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setup-nav" style={{ background: '#f1f5f9', padding: '4px', borderRadius: '12px' }}>
|
||||||
|
{statuses.map(status => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
className={`setup-nav-item ${statusFilter === status ? 'active' : ''}`}
|
||||||
|
onClick={() => setStatusFilter(status)}
|
||||||
|
style={{ fontSize: '0.7rem', fontWeight: 700, padding: '6px 14px' }}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={loadAdmissions}
|
||||||
|
style={{
|
||||||
|
width: '40px', height: '40px', borderRadius: '12px', border: '1px solid #e2e8f0',
|
||||||
|
background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} color="#64748b" className={isLoading ? 'spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isLoading && admissions.length === 0 ? (
|
||||||
|
<div style={{ padding: '80px 40px', textAlign: 'center' }}>
|
||||||
|
<div className="spin" style={{ width: 32, height: 32, border: '3px solid #e2e8f0', borderTop: '3px solid #8b5cf6', borderRadius: '50%', margin: '0 auto 16px auto' }} />
|
||||||
|
<p style={{ color: '#64748b', fontWeight: 600 }}>Loading admissions...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredAdmissions.length === 0 ? (
|
||||||
|
<div style={{ padding: '80px 40px', textAlign: 'center', background: '#f8fafc', borderRadius: '32px', border: '2px dashed #e2e8f0' }}>
|
||||||
|
<div style={{ width: 80, height: 80, borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px auto', boxShadow: '0 10px 25px rgba(0,0,0,0.05)' }}>
|
||||||
|
<BedDouble size={32} color="#94a3b8" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ color: '#1e293b', margin: '0 0 8px 0', fontWeight: 800 }}>No Admissions Found</h3>
|
||||||
|
<p style={{ color: '#64748b', fontSize: '0.9rem', maxWidth: '300px', margin: '8px auto 0 auto' }}>
|
||||||
|
{searchQuery || statusFilter !== 'ALL' ? 'No records match your current filters.' : 'No patients have been admitted yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredAdmissions.map((admission) => (
|
||||||
|
<motion.div
|
||||||
|
key={admission.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status indicator bar */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 0, top: 0, bottom: 0, width: '4px',
|
||||||
|
background: admission.status === 'ADMITTED' ? '#8b5cf6' : '#10b981',
|
||||||
|
borderRadius: '20px 0 0 20px',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '24px 24px 24px 28px',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr auto',
|
||||||
|
gap: '24px',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
{/* Patient Info */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Patient</div>
|
||||||
|
<div style={{ fontSize: '1.05rem', fontWeight: 800, color: '#1e293b' }}>
|
||||||
|
{admission.patient?.name || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '6px', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{
|
||||||
|
background: admission.patient?.triage_code === 'RED' ? 'hsla(0, 84%, 60%, 0.1)' : 'hsla(38, 92%, 50%, 0.1)',
|
||||||
|
color: admission.patient?.triage_code === 'RED' ? '#ef4444' : '#f59e0b',
|
||||||
|
padding: '2px 8px', borderRadius: '6px', fontSize: '0.65rem', fontWeight: 800,
|
||||||
|
}}>
|
||||||
|
{admission.patient?.triage_code || '--'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#64748b', fontSize: '0.75rem', fontWeight: 600 }}>
|
||||||
|
{admission.patient?.age || '--'}Y • {admission.patient?.gender || '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{admission.patient?.chief_complaint && (
|
||||||
|
<div style={{ marginTop: '6px', fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
|
||||||
|
{admission.patient.chief_complaint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Department Info */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Department</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Building2 size={16} color="#8b5cf6" />
|
||||||
|
<span style={{ fontSize: '0.95rem', fontWeight: 700, color: '#1e293b' }}>
|
||||||
|
{admission.department?.name || '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600, marginTop: '6px' }}>
|
||||||
|
HOD: {admission.department?.headOfDepartment || '--'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '6px' }}>
|
||||||
|
<span style={{ fontSize: '0.7rem', color: '#64748b' }}>
|
||||||
|
Beds: <strong style={{ color: '#1e293b' }}>{admission.department?.occupiedBeds || 0}/{admission.department?.totalBedsCapacity || 0}</strong>
|
||||||
|
</span>
|
||||||
|
{admission.bed_type && (
|
||||||
|
<span style={{ fontSize: '0.7rem', background: '#f1f5f9', padding: '1px 6px', borderRadius: '4px', color: '#475569', fontWeight: 700 }}>
|
||||||
|
{admission.bed_type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.6rem', fontWeight: 900, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>Timeline</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
|
||||||
|
<Clock size={14} color="#8b5cf6" />
|
||||||
|
<span style={{ fontSize: '0.8rem', fontWeight: 700, color: '#1e293b' }}>
|
||||||
|
Admitted {formatDate(admission.admitted_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
|
||||||
|
Duration: <strong style={{ color: '#8b5cf6' }}>{getTimeSince(admission.admitted_at)}</strong>
|
||||||
|
</div>
|
||||||
|
{admission.discharged_at && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px' }}>
|
||||||
|
<CheckCircle2 size={14} color="#10b981" />
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#10b981' }}>
|
||||||
|
Discharged {formatDate(admission.discharged_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'flex-end' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 12px', borderRadius: '8px', fontSize: '0.7rem', fontWeight: 800,
|
||||||
|
background: admission.status === 'ADMITTED' ? 'hsla(262, 83%, 58%, 0.1)' : 'hsla(152, 69%, 40%, 0.1)',
|
||||||
|
color: admission.status === 'ADMITTED' ? '#8b5cf6' : '#10b981',
|
||||||
|
}}>
|
||||||
|
{admission.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{admission.status === 'ADMITTED' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDischargeId(admission.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
|
padding: '8px 16px', borderRadius: '10px',
|
||||||
|
border: '1px solid #e2e8f0', background: '#fff',
|
||||||
|
color: '#ef4444', fontWeight: 700, fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#fef2f2'; e.currentTarget.style.borderColor = '#fecaca'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#fff'; e.currentTarget.style.borderColor = '#e2e8f0'; }}
|
||||||
|
>
|
||||||
|
<LogOut size={14} /> Discharge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discharge Confirmation Modal */}
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{confirmDischargeId && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 10001, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setConfirmDischargeId(null)}
|
||||||
|
style={{ position: 'absolute', inset: 0, background: 'rgba(15, 23, 42, 0.4)', backdropFilter: 'blur(2px)' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
style={{
|
||||||
|
position: 'relative', background: '#fff', borderRadius: '20px',
|
||||||
|
width: '100%', maxWidth: '420px', overflow: 'hidden',
|
||||||
|
boxShadow: '0 20px 40px rgba(0,0,0,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '24px', borderBottom: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fef2f2' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: '12px', background: '#fee2e2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<AlertCircle size={20} color="#ef4444" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800, color: '#1e293b' }}>Confirm Discharge</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDischargeId(null)}
|
||||||
|
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#94a3b8' }}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
{(() => {
|
||||||
|
const adm = admissions.find(a => a.id === confirmDischargeId);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: '#64748b', fontSize: '0.9rem', lineHeight: 1.5, margin: '0 0 20px 0' }}>
|
||||||
|
You are about to discharge the following patient. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div style={{ background: '#f8fafc', borderRadius: '12px', padding: '16px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<div style={{ fontSize: '1rem', fontWeight: 800, color: '#1e293b' }}>{adm?.patient?.name || 'Unknown'}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748b', marginTop: '4px' }}>
|
||||||
|
{adm?.department?.name || '--'} • Admitted {formatDate(adm?.admitted_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px 24px', borderTop: '1px solid #f1f5f9', display: 'flex', justifyContent: 'flex-end', gap: '12px', background: '#f8fafc' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDischargeId(null)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px', borderRadius: '10px', border: '1px solid #e2e8f0',
|
||||||
|
background: '#fff', fontWeight: 700, fontSize: '0.85rem', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDischarge(confirmDischargeId!)}
|
||||||
|
disabled={!!dischargingId}
|
||||||
|
style={{
|
||||||
|
padding: '10px 24px', borderRadius: '10px', border: 'none',
|
||||||
|
background: '#ef4444', color: '#fff', fontWeight: 700, fontSize: '0.85rem',
|
||||||
|
cursor: dischargingId ? 'not-allowed' : 'pointer', opacity: dischargingId ? 0.7 : 1,
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dischargingId ? (
|
||||||
|
<>
|
||||||
|
<div className="spin" style={{ width: 14, height: 14, border: '2px solid rgba(255,255,255,0.3)', borderTop: '2px solid #fff', borderRadius: '50%' }} />
|
||||||
|
Discharging...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogOut size={14} /> Confirm Discharge
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -85,7 +85,7 @@ export const EDMonitor: React.FC<EDMonitorProps> = ({
|
|||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
return {
|
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,
|
critical: incomingPatients.filter(p => (p.triage === 'RED' || p.triage === 'CRITICAL') && p.status !== 'HANDOFF_COMPLETE').length,
|
||||||
onsite: incomingPatients.filter(p => p.status === 'ARRIVED').length,
|
onsite: incomingPatients.filter(p => p.status === 'ARRIVED').length,
|
||||||
handoff: incomingPatients.filter(p => p.status === 'HANDOFF_COMPLETE').length,
|
handoff: incomingPatients.filter(p => p.status === 'HANDOFF_COMPLETE').length,
|
||||||
@@ -106,7 +106,7 @@ export const EDMonitor: React.FC<EDMonitorProps> = ({
|
|||||||
});
|
});
|
||||||
}, [incomingPatients, searchQuery, statusFilter]);
|
}, [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 triageData = useMemo(() => {
|
||||||
const counts: any = { RED: 0, ORANGE: 0, YELLOW: 0, GREEN: 0, WHITE: 0 };
|
const counts: any = { RED: 0, ORANGE: 0, YELLOW: 0, GREEN: 0, WHITE: 0 };
|
||||||
|
|||||||
Reference in New Issue
Block a user