3320 lines
238 KiB
HTML
3320 lines
238 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>Nursing Care - HMS Mobile</title>
|
||
<link rel="manifest" href="/App/Nursing/manifest.json">
|
||
<meta name="theme-color" content="#0f172a">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<link rel="icon" href="/assets/img/logo.png" type="image/png">
|
||
<link rel="apple-touch-icon" href="/assets/img/logo.png">
|
||
|
||
<!-- Google Fonts & Bootstrap 5 CSS & Icons -->
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||
|
||
<!-- React 18 & ReactDOM UMD -->
|
||
<script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js" crossorigin></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone/babel.min.js"></script>
|
||
|
||
<!-- Cropper.js for Document Photo Cropping -->
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css">
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js"></script>
|
||
|
||
<!-- HTML5 QR & Barcode Scanner for Patient Barcodes and ABHA QR -->
|
||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||
|
||
<style>
|
||
:root {
|
||
--primary-bg: #0f172a;
|
||
--card-bg: #ffffff;
|
||
--accent-blue: #2563eb;
|
||
--accent-cyan: #0891b2;
|
||
--accent-green: #16a34a;
|
||
--accent-amber: #d97706;
|
||
--accent-red: #dc2626;
|
||
--slate-800: #1e293b;
|
||
--slate-900: #0f172a;
|
||
}
|
||
|
||
body {
|
||
background-color: #f1f5f9;
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
margin: 0;
|
||
padding: 0;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
overflow-x: hidden;
|
||
color: #334155;
|
||
}
|
||
|
||
/* Android TV D-Pad Remote Focus Glow Outline */
|
||
:focus-visible, button:focus, select:focus, input:focus, .card:focus, a:focus {
|
||
outline: 3px solid #2563eb !important;
|
||
outline-offset: 2px !important;
|
||
box-shadow: 0 0 16px rgba(37, 99, 235, 0.6) !important;
|
||
}
|
||
|
||
/* Topbar Header */
|
||
.nursing-header {
|
||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||
color: #ffffff;
|
||
padding: 12px 16px;
|
||
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.25);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1000;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.avatar-circle {
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||
color: #ffffff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||
border: 2px solid rgba(255,255,255,0.2);
|
||
}
|
||
|
||
.patient-avatar-circle {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 700;
|
||
font-size: 15px;
|
||
border: 1.5px solid #dbeafe;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Segmented Tabs */
|
||
.segmented-tabs {
|
||
background: #e2e8f0;
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.segmented-tab {
|
||
flex: 1;
|
||
border: none;
|
||
background: transparent;
|
||
padding: 10px 14px;
|
||
border-radius: 9px;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.segmented-tab.active {
|
||
background: linear-gradient(135deg, #059669 0%, #047857 100%);
|
||
color: #ffffff;
|
||
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.25);
|
||
}
|
||
|
||
.segmented-tab.active-blue {
|
||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||
color: #ffffff;
|
||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
|
||
}
|
||
|
||
/* Bed Cards */
|
||
.bed-card {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 16px;
|
||
background: #ffffff;
|
||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||
cursor: pointer;
|
||
box-shadow: 0 4px 16px -2px rgba(15, 23, 42, 0.05);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bed-card:hover, .bed-card:active {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 8px 24px -4px rgba(15, 23, 42, 0.12);
|
||
border-color: #cbd5e1;
|
||
}
|
||
|
||
.bed-status-available { border-top: 5px solid #16a34a; }
|
||
.bed-status-occupied { border-top: 5px solid #2563eb; }
|
||
.bed-status-cleaning { border-top: 5px solid #d97706; }
|
||
.bed-status-maintenance { border-top: 5px solid #dc2626; }
|
||
|
||
.mobile-patient-card {
|
||
background: #ffffff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
margin-bottom: 14px;
|
||
box-shadow: 0 4px 16px -2px rgba(15, 23, 42, 0.05);
|
||
border-left: 6px solid var(--accent-blue);
|
||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
|
||
/* Status Pills */
|
||
.status-pill {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
padding: 4px 10px;
|
||
border-radius: 20px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.3px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.status-pill.waiting { background-color: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; }
|
||
.status-pill.visited { background-color: #ecfeff; color: #0e7490; border: 1px solid #a5f3fc; }
|
||
.status-pill.booked { background-color: #f8fafc; color: #475569; border: 1px solid #cbd5e1; }
|
||
|
||
.queue-badge {
|
||
background: #0f172a;
|
||
color: #ffffff;
|
||
font-weight: 800;
|
||
font-size: 12px;
|
||
padding: 4px 10px;
|
||
border-radius: 20px;
|
||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.2);
|
||
}
|
||
|
||
/* Lock Screen Overlay */
|
||
.lock-screen-overlay {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||
z-index: 9999;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.lock-card {
|
||
background: #ffffff;
|
||
border-radius: 20px;
|
||
width: 100%;
|
||
max-width: 400px;
|
||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Fluid I/O Stats Cards */
|
||
.stat-card-intake {
|
||
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
|
||
border: 1.5px solid #a7f3d0;
|
||
border-radius: 14px;
|
||
padding: 10px 8px;
|
||
}
|
||
.stat-card-output {
|
||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||
border: 1.5px solid #fde68a;
|
||
border-radius: 14px;
|
||
padding: 10px 8px;
|
||
}
|
||
.stat-card-balance {
|
||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||
border: 1.5px solid #bfdbfe;
|
||
border-radius: 14px;
|
||
padding: 10px 8px;
|
||
}
|
||
|
||
/* Floating Bottom Dock Action Bar */
|
||
.bottom-dock-container {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 1040;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
border-top: 1px solid #e2e8f0;
|
||
box-shadow: 0 -6px 25px rgba(15, 23, 42, 0.1);
|
||
padding: 8px 12px;
|
||
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||
display: flex;
|
||
gap: 10px;
|
||
max-width: 600px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.bottom-dock-btn {
|
||
border: none;
|
||
border-radius: 14px;
|
||
padding: 8px 6px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 3px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||
color: #ffffff;
|
||
text-decoration: none;
|
||
flex: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.bottom-dock-btn:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.bottom-dock-btn.btn-scan {
|
||
background: linear-gradient(135deg, #0f172a 0%, #334155 100%);
|
||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.25);
|
||
}
|
||
|
||
.bottom-dock-btn.btn-vitals {
|
||
background: linear-gradient(135deg, #059669 0%, #047857 100%);
|
||
box-shadow: 0 4px 14px rgba(5, 150, 105, 0.25);
|
||
}
|
||
|
||
.bottom-dock-btn.btn-doc {
|
||
background: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%);
|
||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.25);
|
||
}
|
||
|
||
.content-with-bottom-dock {
|
||
padding-bottom: 96px !important;
|
||
}
|
||
|
||
.qr-scanner-wrapper {
|
||
position: relative;
|
||
background: #000000;
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
border: 2px solid #3b82f6;
|
||
min-height: 240px;
|
||
}
|
||
|
||
.patient-search-result-card {
|
||
background: #ffffff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 14px;
|
||
padding: 12px;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||
}
|
||
.patient-search-result-card:hover {
|
||
border-color: #93c5fd;
|
||
box-shadow: 0 4px 14px rgba(37,99,235,0.08);
|
||
}
|
||
|
||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
|
||
<script type="text/babel">
|
||
const { useState, useEffect } = React;
|
||
|
||
const DEVICE_AUTH_KEY = 'nursing_pwa_device_auth_v1';
|
||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||
|
||
const MobileAppsModal = ({ show, onClose, currentApp }) => {
|
||
if (!show) return null;
|
||
|
||
const navigateToApp = (appKey) => {
|
||
if (appKey === currentApp) {
|
||
onClose();
|
||
return;
|
||
}
|
||
if (appKey === 'doctor') {
|
||
window.location.href = '/app/doctor';
|
||
} else if (appKey === 'nursing') {
|
||
window.location.href = '/app/nursing';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(15, 23, 42, 0.7)', zIndex: 1055, backdropFilter: 'blur(4px)' }} tabIndex="-1">
|
||
<div className="modal-dialog modal-dialog-centered px-2">
|
||
<div className="modal-content border-0 shadow-lg rounded-4 overflow-hidden">
|
||
<div className="modal-header text-white p-3" style={{ background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)' }}>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<img src="/assets/img/logo.png" alt="Hospital Logo" style={{ height: '28px', width: 'auto', filter: 'brightness(0) invert(1)' }} onError={(e) => { e.target.style.display = 'none'; }} />
|
||
<h6 className="modal-title fw-bold mb-0 text-white" style={{ fontSize: '15px' }}>
|
||
<i className="bi bi-grid-fill me-1 text-primary"></i> HMS Mobile & PWA Apps Launcher
|
||
</h6>
|
||
</div>
|
||
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
|
||
</div>
|
||
<div className="modal-body p-3 bg-light">
|
||
<label className="form-label fw-bold small text-secondary mb-2" style={{ letterSpacing: '0.5px' }}>
|
||
1. SELECT APPLICATION
|
||
</label>
|
||
<div className="row g-2 mb-3">
|
||
<div className="col-4">
|
||
<div
|
||
className={`card p-2 text-center h-100 ${currentApp === 'nursing' ? 'border-2 border-primary bg-primary-subtle shadow-sm' : 'border bg-white text-dark'}`}
|
||
style={{ cursor: 'pointer', borderRadius: '12px' }}
|
||
onClick={() => navigateToApp('nursing')}
|
||
>
|
||
<div className="my-1">
|
||
<i className="bi bi-heart-pulse-fill fs-3 text-primary"></i>
|
||
</div>
|
||
<div className="fw-bold small text-truncate" style={{ fontSize: '12px' }}>Nursing Care</div>
|
||
{currentApp === 'nursing' ? (
|
||
<span className="badge bg-primary mt-1" style={{ fontSize: '9px' }}>● Active App</span>
|
||
) : (
|
||
<span className="badge bg-info-subtle text-info-emphasis border border-info-subtle mt-1" style={{ fontSize: '9px' }}>Active PWA</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="col-4">
|
||
<div
|
||
className={`card p-2 text-center h-100 ${currentApp === 'doctor' ? 'border-2 border-primary bg-primary-subtle shadow-sm' : 'border bg-white text-dark'}`}
|
||
style={{ cursor: 'pointer', borderRadius: '12px' }}
|
||
onClick={() => navigateToApp('doctor')}
|
||
>
|
||
<div className="my-1">
|
||
<i className="bi bi-person-badge-fill fs-3 text-primary"></i>
|
||
</div>
|
||
<div className="fw-bold small text-truncate" style={{ fontSize: '12px' }}>Doctor App</div>
|
||
{currentApp === 'doctor' ? (
|
||
<span className="badge bg-primary mt-1" style={{ fontSize: '9px' }}>● Active App</span>
|
||
) : (
|
||
<span className="badge bg-success mt-1" style={{ fontSize: '9px' }}>Active PWA</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="col-4">
|
||
<div
|
||
className="card p-2 text-center border bg-white text-muted opacity-75 h-100"
|
||
style={{ borderRadius: '12px', cursor: 'not-allowed' }}
|
||
>
|
||
<div className="my-1">
|
||
<i className="bi bi-capsule fs-3 text-secondary"></i>
|
||
</div>
|
||
<div className="fw-bold small text-truncate" style={{ fontSize: '12px' }}>Pharmacy App</div>
|
||
<span className="badge bg-secondary mt-1" style={{ fontSize: '9px' }}>Coming Soon</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-3 bg-white rounded-3 border text-center shadow-sm">
|
||
<div className="d-flex align-items-center justify-content-center gap-2 mb-2">
|
||
<img src="/assets/img/logo.png" alt="Logo" style={{ height: '28px' }} onError={(e) => e.target.style.display = 'none'} />
|
||
<div className="text-start">
|
||
<div className="fw-bold small text-dark">HMS Unified Mobile Portal</div>
|
||
<div className="small text-muted" style={{ fontSize: '10px' }}>Domain: {window.location.host}</div>
|
||
</div>
|
||
</div>
|
||
<p className="small text-muted mb-2" style={{ fontSize: '11px' }}>
|
||
Tap any active app card above to switch between modules in your mobile app.
|
||
</p>
|
||
<a href="/app" className="btn btn-outline-primary btn-sm w-100 fw-bold rounded-pill" style={{ fontSize: '12px' }}>
|
||
<i className="bi bi-house-door me-1"></i> Open Mobile App Hub
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function NursingApp() {
|
||
const [showAppsModal, setShowAppsModal] = useState(false);
|
||
const [mainTab, setMainTab] = useState('ipd'); // 'ipd' or 'opd'
|
||
const [beds, setBeds] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedWard, setSelectedWard] = useState('all');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [activeBed, setActiveBed] = useState(null);
|
||
const [activeTab, setActiveTab] = useState('vitals');
|
||
const [workspaceData, setWorkspaceData] = useState([]);
|
||
const [nurses, setNurses] = useState([]);
|
||
|
||
// OPD Patients State
|
||
const [opdAppointments, setOpdAppointments] = useState([]);
|
||
const [opdCounts, setOpdCounts] = useState({ all: 0, waiting: 0, visited: 0, booked: 0, cancelled: 0, pending_vitals: 0, done_vitals: 0 });
|
||
const [opdFilter, setOpdFilter] = useState('all');
|
||
const [opdDateMode, setOpdDateMode] = useState('today');
|
||
const [doctors, setDoctors] = useState([]);
|
||
const [selectedDoctorId, setSelectedDoctorId] = useState('0');
|
||
const [vitalsFilter, setVitalsFilter] = useState('all'); // 'all', 'pending', 'done'
|
||
|
||
// Modals & Action State
|
||
const [activeOpdForVitals, setActiveOpdForVitals] = useState(null);
|
||
const [opdVitalsForm, setOpdVitalsForm] = useState({
|
||
temp: '', pulse: '', bp_systolic: '', bp_diastolic: '', spo2: '', weight: '', height: '', rr_min: ''
|
||
});
|
||
|
||
const [scanTarget, setScanTarget] = useState(null); // OPD or IPD object
|
||
const [scanType, setScanType] = useState('opd'); // 'opd' or 'ipd'
|
||
const [scanDocCategory, setScanDocCategory] = useState('Paper Document');
|
||
const [scanImageBase64, setScanImageBase64] = useState('');
|
||
const [scanImagePreview, setScanImagePreview] = useState('');
|
||
|
||
// ABDM M2 & Patient Search / Barcode Scanner State
|
||
const [showPatientSearchModal, setShowPatientSearchModal] = useState(false);
|
||
const [searchModalTab, setSearchModalTab] = useState('scan'); // 'scan' or 'type'
|
||
const [patientSearchQuery, setPatientSearchQuery] = useState('');
|
||
const [patientSearchResults, setPatientSearchResults] = useState([]);
|
||
const [searchingPatient, setSearchingPatient] = useState(false);
|
||
const [scannerActive, setScannerActive] = useState(false);
|
||
const [scannerError, setScannerError] = useState('');
|
||
|
||
// ABDM M2 Wellness Vitals Modal State
|
||
const [activeWellnessPatient, setActiveWellnessPatient] = useState(null);
|
||
const [wellnessModalTab, setWellnessModalTab] = useState('vitals'); // 'vitals', 'sugar', 'activity', 'lifestyle', 'women'
|
||
const [wellnessForm, setWellnessForm] = useState({
|
||
temperature_f: '', pulse_rate: '', resp_rate: '',
|
||
bp_systolic: '', bp_diastolic: '', spo2: '', weight_kg: '', height_cm: '',
|
||
pain_score: '',
|
||
waist_circumference_cm: '', hip_circumference_cm: '', waist_hip_ratio: '',
|
||
sugar_random: '', sugar_fasting: '', sugar_pp: '', hba1c: '', hemoglobin: '',
|
||
daily_steps: '', sleep_hours: '', exercise_min_per_day: '',
|
||
diet_type: '', tobacco_status: '', alcohol_status: '',
|
||
women_lmp: '', women_pregnancy_status: '',
|
||
general_advice: '', diet_lifestyle_note: ''
|
||
});
|
||
|
||
// ABDM M2 Health Document Modal State
|
||
const [activeDocPatient, setActiveDocPatient] = useState(null);
|
||
const [patientDocForm, setPatientDocForm] = useState({
|
||
document_type: 'Health Document',
|
||
document_date: new Date().toISOString().slice(0, 10),
|
||
remarks: ''
|
||
});
|
||
const [isSavingDoc, setIsSavingDoc] = useState(false);
|
||
|
||
// Vitals History Modal State
|
||
const [wellnessHistoryPatient, setWellnessHistoryPatient] = useState(null);
|
||
const [wellnessHistoryList, setWellnessHistoryList] = useState([]);
|
||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||
|
||
// Auth & Device Security State
|
||
const PAIRED_NURSE_KEY = 'hms_nursing_paired_profile';
|
||
const [authenticatedNurse, setAuthenticatedNurse] = useState(null);
|
||
const [pairedNurse, setPairedNurse] = useState(() => {
|
||
try {
|
||
const item = localStorage.getItem(PAIRED_NURSE_KEY);
|
||
return item ? JSON.parse(item) : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
});
|
||
const [staffIdInput, setStaffIdInput] = useState('');
|
||
const [deviceCodeInput, setDeviceCodeInput] = useState('');
|
||
const [pinInput, setPinInput] = useState('');
|
||
const [pinError, setPinError] = useState('');
|
||
const [showPairingForm, setShowPairingForm] = useState(false);
|
||
const [isVerifying, setIsVerifying] = useState(false);
|
||
const [isSettingNewPin, setIsSettingNewPin] = useState(false);
|
||
const [newPin, setNewPin] = useState('');
|
||
const [confirmPin, setConfirmPin] = useState('');
|
||
|
||
// Workspace Forms (IPD)
|
||
const [vitalsForm, setVitalsForm] = useState({
|
||
temperature_f: '', pulse_rate: '', resp_rate: '',
|
||
bp_systolic: '', bp_diastolic: '', spo2: '', weight_kg: '', general_note: ''
|
||
});
|
||
|
||
const [fluidForm, setFluidForm] = useState({
|
||
fluid_direction: 'intake', fluid_route: 'IV (Intravenous)',
|
||
fluid_amount_ml: '', general_note: ''
|
||
});
|
||
|
||
const [treatmentForm, setTreatmentForm] = useState({
|
||
treatment_text: '', general_note: ''
|
||
});
|
||
|
||
const [showAllDoctorOrders, setShowAllDoctorOrders] = useState(false);
|
||
|
||
const fetchBeds = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await fetch('/api/v1/nursing/beds');
|
||
const json = await res.json();
|
||
if (json.status === 1 && json.data) {
|
||
setBeds(json.data.beds || []);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load beds', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchDoctors = async () => {
|
||
try {
|
||
const res = await fetch('/api/v1/doctor/list');
|
||
const json = await res.json();
|
||
if (json.status === 1 && json.data) {
|
||
setDoctors(json.data);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load doctors', err);
|
||
}
|
||
};
|
||
|
||
const fetchOpdAppointments = async (dateModeOverride, docIdOverride, vitalsOverride) => {
|
||
try {
|
||
const mode = dateModeOverride !== undefined ? dateModeOverride : opdDateMode;
|
||
const docId = docIdOverride !== undefined ? docIdOverride : selectedDoctorId;
|
||
const vFilter = vitalsOverride !== undefined ? vitalsOverride : vitalsFilter;
|
||
|
||
const res = await fetch(`/api/v1/nursing/opd/list?date_mode=${mode}&doctor_id=${docId}&vitals_status=${vFilter}`);
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
setOpdAppointments(json.appointments || []);
|
||
if (json.counts) {
|
||
setOpdCounts(json.counts);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load OPD appointments', err);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const savedAuth = localStorage.getItem(DEVICE_AUTH_KEY);
|
||
if (savedAuth) {
|
||
try {
|
||
const parsed = JSON.parse(savedAuth);
|
||
if (parsed && parsed.nurse && parsed.expires_at > Date.now()) {
|
||
setAuthenticatedNurse(parsed.nurse);
|
||
} else {
|
||
localStorage.removeItem(DEVICE_AUTH_KEY);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
fetchBeds();
|
||
fetchDoctors();
|
||
fetchOpdAppointments('today', '0', 'all');
|
||
}, []);
|
||
|
||
const handleVerifyPin = async () => {
|
||
const isPairing = !pairedNurse || showPairingForm;
|
||
const targetStaffId = isPairing ? staffIdInput.trim() : (pairedNurse.staff_id || '');
|
||
if (!targetStaffId) {
|
||
setPinError('Please enter your Staff ID or Username.');
|
||
return;
|
||
}
|
||
if (isPairing && (!deviceCodeInput || deviceCodeInput.trim().length < 4)) {
|
||
setPinError('Please enter the 6-digit Device Security Code from Nursing Staff Master.');
|
||
return;
|
||
}
|
||
if (!pinInput || pinInput.length < 4) {
|
||
setPinError('Enter your 4 to 6 digit Security PIN or Password.');
|
||
return;
|
||
}
|
||
|
||
setPinError('');
|
||
setIsVerifying(true);
|
||
try {
|
||
const body = new URLSearchParams();
|
||
body.append('staff_id', targetStaffId);
|
||
body.append('pin', pinInput);
|
||
if (isPairing) {
|
||
body.append('device_code', deviceCodeInput.trim());
|
||
} else if (pairedNurse && pairedNurse.device_token) {
|
||
body.append('device_token', pairedNurse.device_token);
|
||
}
|
||
|
||
const res = await fetch('/api/v1/nursing/auth/verify-pin', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: body.toString()
|
||
});
|
||
const json = await res.json();
|
||
|
||
if (json.status === 1 && json.nurse) {
|
||
setAuthenticatedNurse(json.nurse);
|
||
const deviceAuthObj = {
|
||
nurse: json.nurse,
|
||
expires_at: Date.now() + THIRTY_DAYS_MS
|
||
};
|
||
localStorage.setItem(DEVICE_AUTH_KEY, JSON.stringify(deviceAuthObj));
|
||
|
||
const pairedObj = {
|
||
id: json.nurse.id,
|
||
staff_id: json.nurse.nurse_code || targetStaffId,
|
||
name: json.nurse.name,
|
||
device_token: json.device_token || ''
|
||
};
|
||
localStorage.setItem(PAIRED_NURSE_KEY, JSON.stringify(pairedObj));
|
||
setPairedNurse(pairedObj);
|
||
setShowPairingForm(false);
|
||
setPinInput('');
|
||
setDeviceCodeInput('');
|
||
fetchBeds();
|
||
fetchOpdAppointments(opdDateMode, selectedDoctorId, vitalsFilter);
|
||
} else if (json.status === 2) {
|
||
setIsSettingNewPin(true);
|
||
} else {
|
||
if (json.need_device_code) {
|
||
setShowPairingForm(true);
|
||
}
|
||
setPinError(json.message || 'Incorrect Security PIN or Device Code.');
|
||
}
|
||
} catch (err) {
|
||
setPinError('Connection error verifying PIN.');
|
||
} finally {
|
||
setIsVerifying(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveNewPin = async () => {
|
||
if (!newPin || newPin.length < 4) {
|
||
setPinError('PIN must be 4 to 6 numeric digits.');
|
||
return;
|
||
}
|
||
if (newPin !== confirmPin) {
|
||
setPinError('New PIN and Confirm PIN do not match.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const targetNurseId = pairedNurse ? pairedNurse.id : (authenticatedNurse ? authenticatedNurse.id : 0);
|
||
const body = new URLSearchParams();
|
||
body.append('nurse_id', targetNurseId);
|
||
body.append('staff_id', staffIdInput.trim() || (pairedNurse ? pairedNurse.staff_id : ''));
|
||
body.append('new_pin', newPin);
|
||
|
||
const res = await fetch('/api/v1/nursing/auth/set-pin', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: body.toString()
|
||
});
|
||
const json = await res.json();
|
||
|
||
if (json.status === 1) {
|
||
alert('Security PIN set successfully! Enter your PIN to unlock.');
|
||
setIsSettingNewPin(false);
|
||
setNewPin('');
|
||
setConfirmPin('');
|
||
setPinInput('');
|
||
} else {
|
||
setPinError(json.message || 'Failed to update PIN');
|
||
}
|
||
} catch (err) {
|
||
setPinError('Error setting PIN.');
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
setAuthenticatedNurse(null);
|
||
localStorage.removeItem(DEVICE_AUTH_KEY);
|
||
setPinInput('');
|
||
setActiveBed(null);
|
||
};
|
||
|
||
const handleUnpairDevice = () => {
|
||
if (confirm('Unpair this device? You will need the Staff ID and Device Security Code to pair it again.')) {
|
||
setAuthenticatedNurse(null);
|
||
setPairedNurse(null);
|
||
localStorage.removeItem(DEVICE_AUTH_KEY);
|
||
localStorage.removeItem(PAIRED_NURSE_KEY);
|
||
setShowPairingForm(true);
|
||
setPinInput('');
|
||
setStaffIdInput('');
|
||
setDeviceCodeInput('');
|
||
setPinError('');
|
||
}
|
||
};
|
||
|
||
const openWorkspace = async (bed) => {
|
||
if (!bed.ipd_id || bed.status !== 'occupied') return;
|
||
setActiveBed(bed);
|
||
try {
|
||
const res = await fetch(`/api/v1/nursing/workspace/${bed.ipd_id}`);
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
setWorkspaceData(json.entries || []);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load workspace', err);
|
||
}
|
||
};
|
||
|
||
const handleSaveIpdEntry = async (entryType, formData) => {
|
||
if (!activeBed || !activeBed.ipd_id) return;
|
||
|
||
try {
|
||
const payload = {
|
||
entry_type: entryType,
|
||
recorded_by: authenticatedNurse ? authenticatedNurse.name : 'Staff Nurse',
|
||
recorded_by_id: authenticatedNurse ? authenticatedNurse.id : 0,
|
||
...formData
|
||
};
|
||
|
||
const res = await fetch(`/api/v1/nursing/entry/save/${activeBed.ipd_id}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
alert(json.message || 'Saved successfully!');
|
||
if (entryType === 'vitals') {
|
||
setVitalsForm({
|
||
temperature_f: '', pulse_rate: '', resp_rate: '',
|
||
bp_systolic: '', bp_diastolic: '', spo2: '', weight_kg: '', general_note: ''
|
||
});
|
||
} else if (entryType === 'fluid') {
|
||
setFluidForm({
|
||
fluid_direction: 'intake', fluid_route: 'IV (Intravenous)',
|
||
fluid_amount_ml: '', general_note: ''
|
||
});
|
||
} else if (entryType === 'treatment') {
|
||
setTreatmentForm({
|
||
treatment_text: '', general_note: ''
|
||
});
|
||
}
|
||
openWorkspace(activeBed);
|
||
} else {
|
||
alert(json.message || 'Failed to save entry');
|
||
}
|
||
} catch (err) {
|
||
alert('Error saving nursing entry');
|
||
}
|
||
};
|
||
|
||
// Open OPD Vitals Modal
|
||
const openOpdVitalsModal = (app) => {
|
||
setActiveOpdForVitals(app);
|
||
setOpdVitalsForm({
|
||
temp: app.temp || '',
|
||
pulse: app.pulse || '',
|
||
bp_systolic: app.bp || '',
|
||
bp_diastolic: app.diastolic || '',
|
||
spo2: app.spo2 || '',
|
||
weight: app.weight || '',
|
||
height: app.height || '',
|
||
rr_min: app.rr_min || ''
|
||
});
|
||
};
|
||
|
||
const handleSaveOpdVitals = async () => {
|
||
if (!activeOpdForVitals) return;
|
||
|
||
try {
|
||
const payload = {
|
||
nurse_id: authenticatedNurse ? authenticatedNurse.id : 0,
|
||
nurse_name: authenticatedNurse ? authenticatedNurse.name : 'Nurse',
|
||
...opdVitalsForm
|
||
};
|
||
|
||
const res = await fetch(`/api/v1/nursing/opd/vitals/save/${activeOpdForVitals.opd_id}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
alert(json.message || 'OPD Patient Vitals saved successfully!');
|
||
setActiveOpdForVitals(null);
|
||
fetchOpdAppointments(opdDateMode);
|
||
} else {
|
||
alert(json.message || 'Failed to save vitals');
|
||
}
|
||
} catch (err) {
|
||
alert('Error saving OPD Vitals');
|
||
}
|
||
};
|
||
|
||
// Open Document Scan Modal (OPD / IPD)
|
||
const openScanModal = (targetObj, targetType) => {
|
||
setScanTarget(targetObj);
|
||
setScanType(targetType);
|
||
setScanDocCategory('Paper Document');
|
||
setScanImageBase64('');
|
||
setScanImagePreview('');
|
||
};
|
||
|
||
const [cropSrc, setCropSrc] = useState('');
|
||
const [cropper, setCropper] = useState(null);
|
||
const imageRef = React.useRef(null);
|
||
|
||
const handleScanImageCapture = (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
setCropSrc(event.target.result);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
e.target.value = '';
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (cropSrc && imageRef.current) {
|
||
if (cropper) {
|
||
cropper.destroy();
|
||
}
|
||
const newCropper = new Cropper(imageRef.current, {
|
||
viewMode: 1,
|
||
autoCropArea: 0.9,
|
||
responsive: true,
|
||
restore: false,
|
||
guides: true,
|
||
center: true,
|
||
highlight: true,
|
||
cropBoxMovable: true,
|
||
cropBoxResizable: true,
|
||
toggleDragModeOnDblclick: false,
|
||
});
|
||
setCropper(newCropper);
|
||
}
|
||
}, [cropSrc]);
|
||
|
||
const applyCrop = () => {
|
||
if (cropper) {
|
||
const croppedCanvas = cropper.getCroppedCanvas({
|
||
maxWidth: 1600,
|
||
maxHeight: 1600,
|
||
imageSmoothingEnabled: true,
|
||
imageSmoothingQuality: 'high',
|
||
});
|
||
if (croppedCanvas) {
|
||
const croppedBase64 = croppedCanvas.toDataURL('image/jpeg', 0.85);
|
||
setScanImageBase64(croppedBase64);
|
||
setScanImagePreview(croppedBase64);
|
||
}
|
||
cropper.destroy();
|
||
setCropper(null);
|
||
setCropSrc('');
|
||
}
|
||
};
|
||
|
||
const cancelCrop = () => {
|
||
if (cropper) {
|
||
cropper.destroy();
|
||
setCropper(null);
|
||
}
|
||
setCropSrc('');
|
||
};
|
||
|
||
const handleSaveScanDocument = async () => {
|
||
if (!scanTarget || !scanImageBase64) {
|
||
alert('Please capture or select a document photo.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const payload = {
|
||
nurse_id: authenticatedNurse ? authenticatedNurse.id : 0,
|
||
nurse_name: authenticatedNurse ? authenticatedNurse.name : 'Nurse',
|
||
document_type: scanDocCategory,
|
||
image_base64: scanImageBase64
|
||
};
|
||
|
||
const url = scanType === 'opd'
|
||
? `/api/v1/nursing/opd/scan/save/${scanTarget.opd_id}`
|
||
: `/api/v1/nursing/ipd/scan/save/${scanTarget.ipd_id || scanTarget.id}`;
|
||
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
alert(json.message || 'Scanned Document uploaded successfully!');
|
||
setScanTarget(null);
|
||
if (scanType === 'opd') fetchOpdAppointments(opdDateMode);
|
||
if (scanType === 'ipd' && activeBed) openWorkspace(activeBed);
|
||
} else {
|
||
alert(json.message || 'Failed to save document scan');
|
||
}
|
||
} catch (err) {
|
||
alert('Error saving document scan');
|
||
}
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// ABDM M2 PATIENT SEARCH & BARCODE / QR SCANNER HANDLERS
|
||
// -----------------------------------------------------------------
|
||
let scannerInstance = null;
|
||
|
||
const startBarcodeQrScanner = () => {
|
||
if (typeof Html5Qrcode === 'undefined') {
|
||
setScannerError('Scanner library is loading. Please try in a moment.');
|
||
return;
|
||
}
|
||
setScannerError('');
|
||
setScannerActive(true);
|
||
|
||
setTimeout(() => {
|
||
const readerElem = document.getElementById('patient-qr-reader');
|
||
if (!readerElem) return;
|
||
|
||
try {
|
||
if (scannerInstance) {
|
||
scannerInstance.stop().catch(() => {}).finally(() => {
|
||
initHtml5QrCode(readerElem);
|
||
});
|
||
} else {
|
||
initHtml5QrCode(readerElem);
|
||
}
|
||
} catch (e) {
|
||
setScannerError('Could not access camera: ' + (e.message || e));
|
||
setScannerActive(false);
|
||
}
|
||
}, 300);
|
||
};
|
||
|
||
const initHtml5QrCode = (readerElem) => {
|
||
try {
|
||
const html5QrCode = new Html5Qrcode("patient-qr-reader");
|
||
scannerInstance = html5QrCode;
|
||
const config = {
|
||
fps: 12,
|
||
qrbox: { width: 280, height: 180 },
|
||
aspectRatio: 1.333334,
|
||
formatsToSupport: [
|
||
Html5QrcodeSupportedFormats.QR_CODE,
|
||
Html5QrcodeSupportedFormats.CODE_128,
|
||
Html5QrcodeSupportedFormats.CODE_39,
|
||
Html5QrcodeSupportedFormats.EAN_13,
|
||
Html5QrcodeSupportedFormats.EAN_8,
|
||
Html5QrcodeSupportedFormats.DATA_MATRIX
|
||
]
|
||
};
|
||
|
||
html5QrCode.start(
|
||
{ facingMode: "environment" },
|
||
config,
|
||
(decodedText) => {
|
||
// On successful scan
|
||
stopBarcodeQrScanner();
|
||
setSearchModalTab('type');
|
||
setPatientSearchQuery(decodedText);
|
||
executePatientSearch(decodedText);
|
||
},
|
||
(errorMessage) => {
|
||
// ignore frame errors
|
||
}
|
||
).catch(err => {
|
||
setScannerError('Camera permission required or camera busy: ' + (err.message || err));
|
||
setScannerActive(false);
|
||
});
|
||
} catch (err) {
|
||
setScannerError('Failed to initialize camera scanner: ' + (err.message || err));
|
||
setScannerActive(false);
|
||
}
|
||
};
|
||
|
||
const stopBarcodeQrScanner = () => {
|
||
if (scannerInstance) {
|
||
try {
|
||
scannerInstance.stop().then(() => {
|
||
scannerInstance.clear();
|
||
scannerInstance = null;
|
||
setScannerActive(false);
|
||
}).catch(() => {
|
||
scannerInstance = null;
|
||
setScannerActive(false);
|
||
});
|
||
} catch (e) {
|
||
scannerInstance = null;
|
||
setScannerActive(false);
|
||
}
|
||
} else {
|
||
setScannerActive(false);
|
||
}
|
||
};
|
||
|
||
const openPatientSearch = (defaultTab = 'scan') => {
|
||
setShowPatientSearchModal(true);
|
||
setSearchModalTab(defaultTab);
|
||
setPatientSearchResults([]);
|
||
setPatientSearchQuery('');
|
||
if (defaultTab === 'scan') {
|
||
startBarcodeQrScanner();
|
||
} else {
|
||
stopBarcodeQrScanner();
|
||
}
|
||
};
|
||
|
||
const closePatientSearch = () => {
|
||
stopBarcodeQrScanner();
|
||
setShowPatientSearchModal(false);
|
||
setPatientSearchResults([]);
|
||
setPatientSearchQuery('');
|
||
};
|
||
|
||
const executePatientSearch = async (queryText) => {
|
||
const q = queryText !== undefined ? queryText : patientSearchQuery;
|
||
if (!q || q.trim() === '') {
|
||
setPatientSearchResults([]);
|
||
return;
|
||
}
|
||
setSearchingPatient(true);
|
||
try {
|
||
const res = await fetch(`/api/v1/nursing/patient/search?q=${encodeURIComponent(q.trim())}`);
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
setPatientSearchResults(json.data || []);
|
||
} else {
|
||
setPatientSearchResults([]);
|
||
}
|
||
} catch (err) {
|
||
console.error('Patient search error', err);
|
||
} finally {
|
||
setSearchingPatient(false);
|
||
}
|
||
};
|
||
|
||
// ABDM M2 Wellness Actions
|
||
const openWellnessModal = (patient) => {
|
||
setActiveWellnessPatient(patient);
|
||
setWellnessModalTab('vitals');
|
||
const opdVitals = patient.opd_today || {};
|
||
setWellnessForm({
|
||
temperature_f: opdVitals.temp || '',
|
||
pulse_rate: opdVitals.pulse || '',
|
||
resp_rate: opdVitals.rr_min || '',
|
||
bp_systolic: opdVitals.bp || '',
|
||
bp_diastolic: opdVitals.diastolic || '',
|
||
spo2: opdVitals.spo2 || '',
|
||
weight_kg: opdVitals.weight || '',
|
||
height_cm: opdVitals.height || '',
|
||
pain_score: '',
|
||
waist_circumference_cm: opdVitals.waist || '',
|
||
hip_circumference_cm: '',
|
||
waist_hip_ratio: '',
|
||
sugar_random: opdVitals.glucose || '',
|
||
sugar_fasting: '',
|
||
sugar_pp: '',
|
||
hba1c: '',
|
||
hemoglobin: '',
|
||
daily_steps: '',
|
||
sleep_hours: '',
|
||
exercise_min_per_day: '',
|
||
diet_type: '',
|
||
tobacco_status: '',
|
||
alcohol_status: '',
|
||
women_lmp: '',
|
||
women_pregnancy_status: '',
|
||
general_advice: '',
|
||
diet_lifestyle_note: ''
|
||
});
|
||
setShowPatientSearchModal(false);
|
||
stopBarcodeQrScanner();
|
||
};
|
||
|
||
const handleSaveWellness = async () => {
|
||
if (!activeWellnessPatient) return;
|
||
try {
|
||
const payload = {
|
||
patient_id: activeWellnessPatient.id,
|
||
recorded_by: authenticatedNurse ? authenticatedNurse.name : 'Staff Nurse',
|
||
recorded_by_id: authenticatedNurse ? authenticatedNurse.id : 0,
|
||
...wellnessForm
|
||
};
|
||
const res = await fetch('/api/v1/nursing/patient/wellness/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
alert('✅ ' + (json.message || 'ABDM M2 Wellness Vitals saved successfully!') + (json.bmi ? ` (BMI: ${json.bmi})` : ''));
|
||
setActiveWellnessPatient(null);
|
||
setWellnessForm({
|
||
temperature_f: '', pulse_rate: '', resp_rate: '',
|
||
bp_systolic: '', bp_diastolic: '', spo2: '', weight_kg: '', height_cm: '',
|
||
pain_score: '',
|
||
waist_circumference_cm: '', hip_circumference_cm: '', waist_hip_ratio: '',
|
||
sugar_random: '', sugar_fasting: '', sugar_pp: '', hba1c: '', hemoglobin: '',
|
||
daily_steps: '', sleep_hours: '', exercise_min_per_day: '',
|
||
diet_type: '', tobacco_status: '', alcohol_status: '',
|
||
women_lmp: '', women_pregnancy_status: '',
|
||
general_advice: '', diet_lifestyle_note: ''
|
||
});
|
||
} else {
|
||
alert(json.message || 'Failed to save wellness vitals');
|
||
}
|
||
} catch (err) {
|
||
alert('Error saving wellness vitals');
|
||
}
|
||
};
|
||
|
||
// ABDM M2 Health Document Actions
|
||
const openDocModal = (patient) => {
|
||
setActiveDocPatient(patient);
|
||
setScanImageBase64('');
|
||
setScanImagePreview('');
|
||
setPatientDocForm({
|
||
document_type: 'Health Document',
|
||
document_date: new Date().toISOString().slice(0, 10),
|
||
remarks: ''
|
||
});
|
||
setShowPatientSearchModal(false);
|
||
stopBarcodeQrScanner();
|
||
};
|
||
|
||
const handleSavePatientDocument = async () => {
|
||
if (!activeDocPatient || isSavingDoc) return;
|
||
if (!scanImageBase64) {
|
||
alert('Please capture or upload a document photo first.');
|
||
return;
|
||
}
|
||
setIsSavingDoc(true);
|
||
try {
|
||
const payload = {
|
||
patient_id: activeDocPatient.id,
|
||
nurse_name: authenticatedNurse ? authenticatedNurse.name : 'Staff Nurse',
|
||
nurse_id: authenticatedNurse ? authenticatedNurse.id : 0,
|
||
image_base64: scanImageBase64,
|
||
...patientDocForm
|
||
};
|
||
const res = await fetch('/api/v1/nursing/patient/document/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
alert('✅ ' + (json.message || 'ABDM M2 Health Document uploaded successfully!'));
|
||
setActiveDocPatient(null);
|
||
setScanImageBase64('');
|
||
setScanImagePreview('');
|
||
} else {
|
||
alert(json.message || 'Failed to upload health document');
|
||
}
|
||
} catch (err) {
|
||
alert('Error uploading health document');
|
||
} finally {
|
||
setIsSavingDoc(false);
|
||
}
|
||
};
|
||
|
||
// Vitals History Actions
|
||
const openWellnessHistory = async (patient) => {
|
||
setWellnessHistoryPatient(patient);
|
||
setLoadingHistory(true);
|
||
try {
|
||
const res = await fetch(`/api/v1/nursing/patient/wellness/history/${patient.id}`);
|
||
const json = await res.json();
|
||
if (json.status === 1) {
|
||
setWellnessHistoryList(json.records || []);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load history', err);
|
||
} finally {
|
||
setLoadingHistory(false);
|
||
}
|
||
};
|
||
|
||
const getInitials = (nameStr) => {
|
||
if (!nameStr) return 'N';
|
||
const parts = nameStr.trim().split(' ');
|
||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||
return nameStr.substring(0, 2).toUpperCase();
|
||
};
|
||
|
||
const renderNoteContent = (entry) => {
|
||
const text = entry.treatment_text || entry.general_note || '';
|
||
const match = text.match(/\[IMAGE_ATTACHMENT:(.*?)\]/);
|
||
const cleanText = text.replace(/\[IMAGE_ATTACHMENT:.*?\]/, '').replace('[Doctor Note] ', '');
|
||
|
||
return (
|
||
<div>
|
||
{cleanText && <div className="small fw-semibold">{cleanText}</div>}
|
||
{match && match[1] && (
|
||
<div className="mt-2">
|
||
<span className="badge bg-primary mb-1 d-inline-flex align-items-center gap-1" style={{fontSize: '10px'}}>
|
||
<i className="bi bi-paperclip"></i> Attached Document / Photo
|
||
</span>
|
||
<a href={match[1]} target="_blank" rel="noreferrer">
|
||
<img src={match[1]} alt="Attached Document" className="img-fluid rounded-3 border shadow-sm d-block mt-1" style={{maxHeight: '220px', objectFit: 'cover'}} />
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const displayTempF = (tempC) => {
|
||
if (tempC === null || tempC === undefined || tempC === '') return '-';
|
||
const val = (parseFloat(tempC) * 9 / 5) + 32;
|
||
return val.toFixed(1) + '°F';
|
||
};
|
||
|
||
const formatDateTime = (dtStr) => {
|
||
if (!dtStr) return '-';
|
||
try {
|
||
const d = new Date(dtStr.replace(/-/g, '/'));
|
||
if (isNaN(d.getTime())) return dtStr;
|
||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||
} catch (e) {
|
||
return dtStr;
|
||
}
|
||
};
|
||
|
||
// Filtered lists: Only Admitted IPD Patients with Bed (Occupied Beds)
|
||
const filteredBeds = beds.filter(bed => {
|
||
const isOccupied = bed.status === 'occupied' || (bed.patient_name && bed.patient_name.trim() !== '') || Boolean(bed.ipd_id);
|
||
const matchesWard = selectedWard === 'all' || bed.ward_name === selectedWard;
|
||
const q = searchQuery.toLowerCase();
|
||
const matchesSearch = searchQuery === '' ||
|
||
(bed.bed_number || '').toLowerCase().includes(q) ||
|
||
(bed.patient_name || '').toLowerCase().includes(q) ||
|
||
(bed.uhid || '').toLowerCase().includes(q) ||
|
||
(bed.ipd_code || '').toLowerCase().includes(q) ||
|
||
(bed.doctor_name || '').toLowerCase().includes(q);
|
||
return isOccupied && matchesWard && matchesSearch;
|
||
});
|
||
|
||
const filteredOpdAppointments = opdAppointments.filter(app => {
|
||
const matchesFilter = opdFilter === 'all' || app.status_key === opdFilter;
|
||
const q = searchQuery.toLowerCase();
|
||
const name = (app.patient_display_name || '').toLowerCase();
|
||
const uhid = (app.uhid || '').toLowerCase();
|
||
const opdCode = (app.opd_code || '').toLowerCase();
|
||
const docName = (app.doctor_name || '').toLowerCase();
|
||
return matchesFilter && (searchQuery === '' || name.includes(q) || uhid.includes(q) || opdCode.includes(q) || docName.includes(q));
|
||
});
|
||
|
||
// -------------------------------------------------------------
|
||
// LOCK SCREEN (DEVICE REGISTRATION & PIN AUTHENTICATION)
|
||
// -------------------------------------------------------------
|
||
if (!authenticatedNurse) {
|
||
const isQuickUnlock = Boolean(pairedNurse && !showPairingForm);
|
||
|
||
return (
|
||
<div className="lock-screen-overlay">
|
||
<MobileAppsModal show={showAppsModal} onClose={() => setShowAppsModal(false)} currentApp="nursing" />
|
||
<div className="lock-card">
|
||
<div className="p-4 text-center text-white position-relative" style={{background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)'}}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-outline-light position-absolute top-0 end-0 m-2 rounded-circle"
|
||
style={{width: '32px', height: '32px', padding: 0}}
|
||
onClick={() => setShowAppsModal(true)}
|
||
title="HMS Mobile & PWA Apps Launcher"
|
||
>
|
||
<i className="bi bi-grid-3x3-gap-fill" style={{fontSize: '12px'}}></i>
|
||
</button>
|
||
<img src="/assets/img/logo.png" alt="Hospital Logo" className="d-block mx-auto mb-2" style={{maxHeight: '40px', filter: 'brightness(0) invert(1)'}} onError={(e) => { e.target.style.display = 'none'; }} />
|
||
<h5 className="fw-bold mb-1">Nursing Care Mobile App</h5>
|
||
<div className="d-flex align-items-center justify-content-center gap-1 opacity-75 small">
|
||
<i className="bi bi-shield-check text-success"></i>
|
||
<span>Hospital Intranet & Device Protected</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4">
|
||
{pinError && <div className="alert alert-danger py-2 px-3 small mb-3">{pinError}</div>}
|
||
|
||
{!isSettingNewPin ? (
|
||
isQuickUnlock ? (
|
||
/* -------------------------------------------------------------
|
||
PAIRED DEVICE QUICK UNLOCK
|
||
------------------------------------------------------------- */
|
||
<div>
|
||
<div className="text-center mb-4">
|
||
<div className="avatar-circle mx-auto mb-2" style={{width: '54px', height: '54px', fontSize: '18px'}}>
|
||
{getInitials(pairedNurse.name || pairedNurse.staff_id)}
|
||
</div>
|
||
<h6 className="fw-bold text-dark mb-0">{pairedNurse.name || 'Staff Nurse'}</h6>
|
||
<span className="badge bg-secondary-subtle text-dark border font-monospace mt-1">
|
||
{pairedNurse.staff_id}
|
||
</span>
|
||
<div className="text-muted small mt-1" style={{fontSize: '11px'}}>
|
||
<i className="bi bi-phone-fill me-1 text-primary"></i> Trusted Hospital Device
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<label className="form-label fw-bold small text-muted">Enter PIN or Password to Unlock</label>
|
||
<input
|
||
type="password"
|
||
maxLength="20"
|
||
className="form-control text-center fs-3 letter-spacing-2"
|
||
placeholder="••••"
|
||
value={pinInput}
|
||
autoFocus
|
||
onChange={(e) => setPinInput(e.target.value)}
|
||
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
|
||
/>
|
||
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
|
||
30-day verified session active
|
||
</small>
|
||
</div>
|
||
|
||
<button
|
||
className="btn btn-success btn-lg w-100 py-3 fw-bold shadow-sm mb-2"
|
||
onClick={handleVerifyPin}
|
||
disabled={isVerifying}
|
||
>
|
||
{isVerifying ? (
|
||
<span><span className="spinner-border spinner-border-sm me-2"></span>Verifying...</span>
|
||
) : (
|
||
<span><i className="bi bi-unlock-fill me-2"></i> Unlock Nursing App</span>
|
||
)}
|
||
</button>
|
||
|
||
<div className="text-center mt-3 pt-2 border-top">
|
||
<button
|
||
type="button"
|
||
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
|
||
onClick={() => { setShowPairingForm(true); setPinError(''); }}
|
||
>
|
||
<i className="bi bi-arrow-repeat me-1"></i> Switch Account / Re-Pair Device
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* -------------------------------------------------------------
|
||
FIRST-TIME DEVICE REGISTRATION / PAIRING FORM
|
||
------------------------------------------------------------- */
|
||
<div>
|
||
<div className="alert alert-primary py-2 px-3 small d-flex align-items-center gap-2 mb-3">
|
||
<i className="bi bi-shield-lock-fill fs-5 text-primary"></i>
|
||
<div>
|
||
<div className="fw-bold">First-Time Device Pairing</div>
|
||
<div style={{fontSize: '11px'}}>Enter Staff ID and Device Security Code from Nursing Staff Master.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-3">
|
||
<label className="form-label fw-bold small text-dark mb-1">
|
||
Staff ID or Username <span className="text-danger">*</span>
|
||
</label>
|
||
<div className="input-group">
|
||
<span className="input-group-text bg-light"><i className="bi bi-person-badge"></i></span>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
placeholder="e.g. NUR-637 or username"
|
||
value={staffIdInput}
|
||
onChange={(e) => setStaffIdInput(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-3">
|
||
<label className="form-label fw-bold small text-dark mb-1">
|
||
Device Security Code <span className="text-danger">*</span>
|
||
</label>
|
||
<div className="input-group">
|
||
<span className="input-group-text bg-light"><i className="bi bi-key-fill text-warning"></i></span>
|
||
<input
|
||
type="text"
|
||
maxLength="10"
|
||
className="form-control font-monospace fw-bold"
|
||
placeholder="6-digit code from Admin"
|
||
value={deviceCodeInput}
|
||
onChange={(e) => setDeviceCodeInput(e.target.value)}
|
||
/>
|
||
</div>
|
||
<small className="text-muted" style={{fontSize: '11px'}}>
|
||
Displayed on HMS Admin > Nursing Staff Master
|
||
</small>
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<label className="form-label fw-bold small text-dark mb-1">
|
||
App PIN or Password <span className="text-danger">*</span>
|
||
</label>
|
||
<div className="input-group">
|
||
<span className="input-group-text bg-light"><i className="bi bi-lock-fill"></i></span>
|
||
<input
|
||
type="password"
|
||
maxLength="20"
|
||
className="form-control"
|
||
placeholder="••••"
|
||
value={pinInput}
|
||
onChange={(e) => setPinInput(e.target.value)}
|
||
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm"
|
||
onClick={handleVerifyPin}
|
||
disabled={isVerifying}
|
||
>
|
||
{isVerifying ? (
|
||
<span><span className="spinner-border spinner-border-sm me-2"></span>Pairing Device...</span>
|
||
) : (
|
||
<span><i className="bi bi-shield-check me-2"></i> Register Device & Unlock</span>
|
||
)}
|
||
</button>
|
||
|
||
{pairedNurse && (
|
||
<div className="text-center mt-3 pt-2 border-top">
|
||
<button
|
||
type="button"
|
||
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
|
||
onClick={() => { setShowPairingForm(false); setPinError(''); }}
|
||
>
|
||
<i className="bi bi-arrow-left me-1"></i> Back to Quick Unlock
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
) : (
|
||
/* -------------------------------------------------------------
|
||
INITIAL PIN CREATION (IF NURSE DOES NOT HAVE A PIN YET)
|
||
------------------------------------------------------------- */
|
||
<div>
|
||
<div className="alert alert-info py-2 small mb-3">
|
||
Initial PIN Setup for Nurse Profile. Set a 4-6 digit Security PIN.
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label fw-bold small">New Security PIN</label>
|
||
<input
|
||
type="password"
|
||
maxLength="6"
|
||
className="form-control text-center fs-3"
|
||
placeholder="••••"
|
||
value={newPin}
|
||
onChange={(e) => setNewPin(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label fw-bold small">Confirm New PIN</label>
|
||
<input
|
||
type="password"
|
||
maxLength="6"
|
||
className="form-control text-center fs-3"
|
||
placeholder="••••"
|
||
value={confirmPin}
|
||
onChange={(e) => setConfirmPin(e.target.value)}
|
||
/>
|
||
</div>
|
||
<button className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm" onClick={handleSaveNewPin}>
|
||
<i className="bi bi-check-circle-fill me-2"></i> Save PIN & Unlock
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// UNLOCKED NURSING PWA APP
|
||
// -------------------------------------------------------------
|
||
return (
|
||
<div>
|
||
<MobileAppsModal show={showAppsModal} onClose={() => setShowAppsModal(false)} currentApp="nursing" />
|
||
{/* Header Topbar */}
|
||
<header className="nursing-header d-flex justify-content-between align-items-center">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<div className="avatar-circle">
|
||
{getInitials(authenticatedNurse.name)}
|
||
</div>
|
||
<div>
|
||
<h6 className="mb-0 fw-bold">{authenticatedNurse.name}</h6>
|
||
<small className="text-light opacity-75" style={{fontSize: '11px'}}>
|
||
{authenticatedNurse.designation || 'Staff Nurse'} <span className="badge bg-success ms-1" style={{fontSize: '9px'}}>● Active</span>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<button className="btn btn-sm btn-outline-light rounded-circle p-2" style={{width: '36px', height: '36px'}} onClick={() => setShowAppsModal(true)} title="HMS Mobile & PWA Apps Launcher">
|
||
<i className="bi bi-grid-3x3-gap-fill"></i>
|
||
</button>
|
||
<button className="btn btn-sm btn-outline-light rounded-circle p-2" style={{width: '36px', height: '36px'}} onClick={() => { fetchBeds(); fetchOpdAppointments(opdDateMode, selectedDoctorId, vitalsFilter); }} title="Refresh">
|
||
<i className="bi bi-arrow-clockwise"></i>
|
||
</button>
|
||
<button className="btn btn-sm btn-outline-danger rounded-circle p-2" style={{width: '36px', height: '36px'}} onClick={handleLogout} title="Lock Access">
|
||
<i className="bi bi-lock-fill"></i>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="container-fluid py-3 px-2 px-md-3 content-with-bottom-dock">
|
||
{!activeBed ? (
|
||
<div>
|
||
{/* Segmented Tab Controller: IPD Admitted vs OPD Patients */}
|
||
<div className="segmented-tabs mb-3">
|
||
<button className={`segmented-tab ${mainTab === 'ipd' ? 'active' : ''}`} onClick={() => { setMainTab('ipd'); fetchBeds(); }}>
|
||
<i className="bi bi-hospital-fill me-2"></i> Admitted IPDs ({beds.filter(b => b.status === 'occupied' || b.patient_name).length})
|
||
</button>
|
||
<button className={`segmented-tab ${mainTab === 'opd' ? 'active-blue' : ''}`} onClick={() => { setMainTab('opd'); fetchOpdAppointments(opdDateMode, selectedDoctorId, vitalsFilter); }}>
|
||
<i className="bi bi-calendar-check-fill me-2"></i> OPD Patients ({opdAppointments.length})
|
||
</button>
|
||
</div>
|
||
|
||
{mainTab === 'ipd' ? (
|
||
/* -------------------------------------------------------------
|
||
ADMITTED IPD PATIENTS & BEDS
|
||
------------------------------------------------------------- */
|
||
<div>
|
||
<div className="row g-2 mb-3 align-items-center">
|
||
<div className="col-12 col-md-6">
|
||
<div className="input-group shadow-sm rounded-pill overflow-hidden bg-white border">
|
||
<span className="input-group-text bg-white border-0 ps-3 text-secondary"><i className="bi bi-search"></i></span>
|
||
<input
|
||
type="text"
|
||
className="form-control border-0 py-2 shadow-none"
|
||
placeholder="Search Bed No, Patient Name, UHID..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<select
|
||
className="form-select rounded-pill shadow-sm py-2"
|
||
value={selectedWard}
|
||
onChange={(e) => setSelectedWard(e.target.value)}
|
||
>
|
||
<option value="all">All Wards / Units</option>
|
||
{Array.from(new Set(beds.map(b => b.ward_name))).filter(Boolean).map(w => (
|
||
<option key={w} value={w}>{w}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="row g-3">
|
||
{filteredBeds.length === 0 ? (
|
||
<div className="col-12 text-center text-muted py-5 bg-white rounded-4 border shadow-sm">
|
||
<i className="bi bi-hospital fs-1 text-secondary opacity-50 d-block mb-2"></i>
|
||
<h6 className="fw-bold text-slate-800 mb-1">No Admitted Patients Found</h6>
|
||
<small className="text-muted">There are currently no admitted IPD patients in the selected ward.</small>
|
||
</div>
|
||
) : (
|
||
filteredBeds.map(bed => (
|
||
<div key={bed.id} className="col-12 col-md-6 col-lg-4">
|
||
<div
|
||
className={`bed-card p-3 bed-status-${bed.status}`}
|
||
onClick={() => openWorkspace(bed)}
|
||
>
|
||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||
<span className="fw-bold text-dark fs-5">Bed {bed.bed_number}</span>
|
||
<span className={`badge ${bed.status === 'occupied' ? 'bg-primary' : 'bg-success'} text-uppercase`} style={{fontSize: '9px'}}>
|
||
{bed.status}
|
||
</span>
|
||
</div>
|
||
<small className="text-muted d-block mb-2" style={{fontSize: '11px'}}>{bed.ward_name || 'General Ward'}</small>
|
||
|
||
{bed.patient_name ? (
|
||
<div>
|
||
<div className="fw-bold text-primary text-truncate" style={{fontSize: '15px'}}>{bed.patient_name}</div>
|
||
<small className="text-muted d-block" style={{fontSize: '11px'}}>UHID: {bed.uhid || 'N/A'} • IPD: {bed.ipd_code || 'N/A'}</small>
|
||
<span className="badge bg-primary-subtle text-primary border border-primary-subtle px-2 py-1 mt-2 d-inline-block" style={{fontSize: '11px'}}>
|
||
<i className="bi bi-person-badge me-1"></i> {bed.doctor_name || 'Dr. Assigned'}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="text-success small fw-semibold py-2">Available for Admission</div>
|
||
)}
|
||
|
||
<div className="mt-3 pt-2 border-top d-flex gap-1">
|
||
<button className="btn btn-sm btn-primary w-100 py-2 fw-bold rounded-3 shadow-sm" style={{fontSize: '12px'}}>
|
||
<i className="bi bi-heart-pulse me-1"></i> Open Bedside Workspace
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* -------------------------------------------------------------
|
||
OPD PATIENTS TAB (VITALS & DOCUMENT SCANNING)
|
||
------------------------------------------------------------- */
|
||
<div>
|
||
{/* Date Mode Segmented Controller & Filters */}
|
||
<div className="d-flex align-items-center justify-content-between mb-3 bg-white p-2 rounded-4 border shadow-sm flex-wrap gap-2">
|
||
<div className="d-flex gap-1 bg-light p-1 rounded-pill border">
|
||
<button
|
||
className={`btn btn-sm rounded-pill px-3 py-1 fw-bold ${opdDateMode === 'today' ? 'btn-primary shadow-sm' : 'btn-light text-secondary'}`}
|
||
onClick={() => { setOpdDateMode('today'); fetchOpdAppointments('today', selectedDoctorId, vitalsFilter); }}
|
||
>
|
||
<i className="bi bi-calendar-event me-1"></i> Today's OPDs ({opdCounts.today_total || 0})
|
||
</button>
|
||
<button
|
||
className={`btn btn-sm rounded-pill px-3 py-1 fw-bold ${opdDateMode === 'all' ? 'btn-primary shadow-sm' : 'btn-light text-secondary'}`}
|
||
onClick={() => { setOpdDateMode('all'); fetchOpdAppointments('all', selectedDoctorId, vitalsFilter); }}
|
||
>
|
||
<i className="bi bi-calendar3 me-1"></i> All Dates ({opdCounts.all_total || 0})
|
||
</button>
|
||
</div>
|
||
<span className="badge bg-primary-subtle text-primary border border-primary-subtle px-2 py-1 rounded-pill small" style={{fontSize: '11px'}}>
|
||
{opdDateMode === 'today' ? '● Current Date' : '● All Appointments'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Doctor Filter & Vitals Filter Controls Box */}
|
||
<div className="bg-white p-3 mb-3 rounded-4 border shadow-sm">
|
||
<div className="row g-2 align-items-center">
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small fw-bold text-dark mb-1">
|
||
<i className="bi bi-person-badge text-primary me-1"></i> Filter by Doctor
|
||
</label>
|
||
<select
|
||
className="form-select form-select-sm rounded-pill fw-semibold"
|
||
value={selectedDoctorId}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setSelectedDoctorId(val);
|
||
fetchOpdAppointments(opdDateMode, val, vitalsFilter);
|
||
}}
|
||
>
|
||
<option value="0">-- All Hospital Doctors --</option>
|
||
{doctors.map(d => (
|
||
<option key={d.id} value={d.id}>{d.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small fw-bold text-dark mb-1">
|
||
<i className="bi bi-heart-pulse-fill text-danger me-1"></i> Vitals Status Filter
|
||
</label>
|
||
<div className="d-flex gap-1">
|
||
<button
|
||
className={`btn btn-sm flex-fill rounded-pill py-1 fw-bold ${vitalsFilter === 'all' ? 'btn-primary shadow-sm' : 'btn-light border text-secondary'}`}
|
||
onClick={() => { setVitalsFilter('all'); fetchOpdAppointments(opdDateMode, selectedDoctorId, 'all'); }}
|
||
>
|
||
All ({opdCounts.all})
|
||
</button>
|
||
<button
|
||
className={`btn btn-sm flex-fill rounded-pill py-1 fw-bold ${vitalsFilter === 'pending' ? 'btn-warning text-dark shadow-sm' : 'btn-light border text-secondary'}`}
|
||
onClick={() => { setVitalsFilter('pending'); fetchOpdAppointments(opdDateMode, selectedDoctorId, 'pending'); }}
|
||
>
|
||
⚠️ Pending ({opdCounts.pending_vitals || 0})
|
||
</button>
|
||
<button
|
||
className={`btn btn-sm flex-fill rounded-pill py-1 fw-bold ${vitalsFilter === 'done' ? 'btn-success text-white shadow-sm' : 'btn-light border text-secondary'}`}
|
||
onClick={() => { setVitalsFilter('done'); fetchOpdAppointments(opdDateMode, selectedDoctorId, 'done'); }}
|
||
>
|
||
✅ Filled ({opdCounts.done_vitals || 0})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Visit Status Filter Pills */}
|
||
<div className="d-flex gap-2 mb-3 overflow-auto no-scrollbar pb-1">
|
||
<button className={`btn btn-sm rounded-pill px-3 ${opdFilter === 'all' ? 'btn-primary fw-bold' : 'btn-white bg-white text-secondary border'}`} onClick={() => setOpdFilter('all')}>
|
||
All Visits
|
||
</button>
|
||
<button className={`btn btn-sm rounded-pill px-3 ${opdFilter === 'waiting' ? 'btn-primary fw-bold' : 'btn-white bg-white text-secondary border'}`} onClick={() => setOpdFilter('waiting')}>
|
||
Waiting
|
||
</button>
|
||
<button className={`btn btn-sm rounded-pill px-3 ${opdFilter === 'visited' ? 'btn-info text-dark fw-bold' : 'btn-white bg-white text-secondary border'}`} onClick={() => setOpdFilter('visited')}>
|
||
Visited
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search Input */}
|
||
<div className="input-group mb-3 shadow-sm rounded-pill overflow-hidden bg-white border">
|
||
<span className="input-group-text bg-white border-0 ps-3 text-secondary"><i className="bi bi-search"></i></span>
|
||
<input
|
||
type="text"
|
||
className="form-control border-0 py-2 shadow-none"
|
||
placeholder="Search Patient Name, Doctor Name, UHID, OPD Code..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* OPD Patients Cards List */}
|
||
<div>
|
||
{filteredOpdAppointments.length === 0 ? (
|
||
<div className="text-center text-muted py-5 bg-white rounded-4 border shadow-sm">
|
||
<i className="bi bi-person-x fs-1 text-secondary opacity-50 d-block mb-2"></i>
|
||
No OPD appointments found for selected filter.
|
||
</div>
|
||
) : (
|
||
filteredOpdAppointments.map(app => (
|
||
<div key={app.opd_id} className="mobile-patient-card">
|
||
<div className="d-flex justify-content-between align-items-center mb-2 pb-2 border-bottom">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<span className="queue-badge">Q #{app.queue_no || app.opd_no || '1'}</span>
|
||
<span className={`status-pill ${app.status_key}`}>● {app.status_label}</span>
|
||
</div>
|
||
<code className="fw-bold text-dark fs-6">{app.opd_code}</code>
|
||
</div>
|
||
|
||
<div className="d-flex align-items-start gap-3 mb-3">
|
||
<div className="patient-avatar-circle">
|
||
{getInitials(app.patient_display_name)}
|
||
</div>
|
||
<div className="flex-grow-1">
|
||
<h5 className="fw-bold text-slate-900 mb-1" style={{fontSize: '16px'}}>
|
||
{app.patient_display_name}
|
||
</h5>
|
||
<small className="text-muted d-block mb-2" style={{fontSize: '12px'}}>
|
||
{app.age_display} • {app.gender_label} | UHID: {app.uhid}
|
||
</small>
|
||
<span className="badge bg-primary-subtle text-primary border border-primary-subtle px-2 py-1" style={{fontSize: '11px'}}>
|
||
<i className="bi bi-person-badge me-1"></i>{app.doctor_name || 'Doctor'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Vitals Summary Pill if recorded */}
|
||
{app.has_vitals && (
|
||
<div className="alert alert-success py-1.5 px-3 mb-3 small rounded-3 d-flex align-items-center justify-content-between" style={{fontSize: '12px'}}>
|
||
<span><i className="bi bi-heart-pulse-fill text-danger me-1"></i> <strong>Vitals:</strong> BP {app.bp || '-'}/{app.diastolic || '-'} | Pulse {app.pulse || '-'} | Temp {app.temp || '-'}°F</span>
|
||
<span className="badge bg-success">Vitals ✓</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Nursing Action Buttons: [ Fill Vitals ] & [ Scan Document ] */}
|
||
<div className="d-flex gap-2">
|
||
<button
|
||
className={`btn ${app.has_vitals ? 'btn-success' : 'btn-outline-success'} w-50 py-2 fw-bold rounded-3 shadow-sm`}
|
||
onClick={() => openOpdVitalsModal(app)}
|
||
>
|
||
<i className="bi bi-heart-pulse me-1"></i> {app.has_vitals ? 'Vitals ✓' : 'Fill Vitals'}
|
||
</button>
|
||
|
||
<button
|
||
className="btn btn-outline-primary w-50 py-2 fw-bold rounded-3 shadow-sm"
|
||
onClick={() => openScanModal(app, 'opd')}
|
||
>
|
||
<i className="bi bi-camera me-1"></i> Scan / Photo
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* -------------------------------------------------------------
|
||
IPD BEDSIDE WORKSPACE FOR NURSING STAFF
|
||
------------------------------------------------------------- */
|
||
<div>
|
||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||
<button className="btn btn-sm btn-outline-dark rounded-pill px-3" onClick={() => setActiveBed(null)}>
|
||
<i className="bi bi-arrow-left me-1"></i> Back to Bed List
|
||
</button>
|
||
<button className="btn btn-sm btn-outline-primary fw-bold rounded-pill px-3" onClick={() => openScanModal(activeBed, 'ipd')}>
|
||
<i className="bi bi-camera me-1"></i> Scan Paper Doc
|
||
</button>
|
||
</div>
|
||
|
||
<div className="card shadow-sm mb-3 border-0 bg-primary text-white rounded-4" style={{background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)'}}>
|
||
<div className="card-body p-3">
|
||
<h5 className="fw-bold mb-1">{activeBed.patient_name}</h5>
|
||
<div className="d-flex flex-wrap gap-2 opacity-90 small mt-2">
|
||
<span className="badge bg-white text-dark">Bed {activeBed.bed_number} ({activeBed.ward_name})</span>
|
||
<span className="badge bg-white text-danger">UHID: {activeBed.uhid}</span>
|
||
<span className="badge bg-white text-primary">IPD: {activeBed.ipd_code}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Form Tabs for Bedside Care */}
|
||
<ul className="nav nav-pills mb-3 bg-white p-1 rounded-3 border shadow-sm">
|
||
<li className="nav-item flex-fill text-center">
|
||
<button className={`nav-link w-100 fw-bold ${activeTab === 'vitals' ? 'active' : ''}`} onClick={() => setActiveTab('vitals')}>
|
||
<i className="bi bi-heart-pulse me-1"></i> Vitals ({workspaceData.filter(e => e.entry_type === 'vitals').length})
|
||
</button>
|
||
</li>
|
||
<li className="nav-item flex-fill text-center">
|
||
<button className={`nav-link w-100 fw-bold ${activeTab === 'fluid' ? 'active' : ''}`} onClick={() => setActiveTab('fluid')}>
|
||
<i className="bi bi-droplet me-1"></i> Fluid I/O ({workspaceData.filter(e => e.entry_type === 'fluid').length})
|
||
</button>
|
||
</li>
|
||
<li className="nav-item flex-fill text-center">
|
||
<button className={`nav-link w-100 fw-bold ${activeTab === 'treatment' ? 'active' : ''}`} onClick={() => setActiveTab('treatment')}>
|
||
<i className="bi bi-clipboard2-pulse me-1"></i> Treatment & Orders ({workspaceData.filter(e => e.entry_type === 'treatment').length})
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
|
||
{/* TAB 1: VITALS (FORM & HISTORY TABLE) */}
|
||
{activeTab === 'vitals' && (
|
||
<div>
|
||
<div className="card p-3 shadow-sm border-0 mb-3 rounded-4">
|
||
<h6 className="fw-bold mb-3 text-dark"><i className="bi bi-heart-pulse-fill text-danger me-1"></i> Record Bedside Vitals</h6>
|
||
<div className="row g-2 mb-3">
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">BP Sys/Dia (mmHg)</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" className="form-control text-center" placeholder="120" value={vitalsForm.bp_systolic} onChange={e => setVitalsForm({...vitalsForm, bp_systolic: e.target.value})} />
|
||
<span className="input-group-text">/</span>
|
||
<input type="number" className="form-control text-center" placeholder="80" value={vitalsForm.bp_diastolic} onChange={e => setVitalsForm({...vitalsForm, bp_diastolic: e.target.value})} />
|
||
</div>
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Pulse Rate (bpm)</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="72" value={vitalsForm.pulse_rate} onChange={e => setVitalsForm({...vitalsForm, pulse_rate: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">SpO2 %</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="98" value={vitalsForm.spo2} onChange={e => setVitalsForm({...vitalsForm, spo2: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Temp °F</label>
|
||
<input type="number" step="0.1" className="form-control form-control-sm text-center" placeholder="98.6" value={vitalsForm.temperature_f} onChange={e => setVitalsForm({...vitalsForm, temperature_f: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Resp Rate (/min)</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="18" value={vitalsForm.resp_rate} onChange={e => setVitalsForm({...vitalsForm, resp_rate: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Weight (kg)</label>
|
||
<input type="number" step="0.1" className="form-control form-control-sm text-center" placeholder="70" value={vitalsForm.weight_kg} onChange={e => setVitalsForm({...vitalsForm, weight_kg: e.target.value})} />
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small mb-1 fw-bold">Nursing Remark / Observation</label>
|
||
<input type="text" className="form-control form-control-sm" placeholder="e.g. Patient resting, vitals stable" value={vitalsForm.general_note} onChange={e => setVitalsForm({...vitalsForm, general_note: e.target.value})} />
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-success fw-bold py-2 w-100 rounded-3 shadow-sm" onClick={() => handleSaveIpdEntry('vitals', vitalsForm)}>
|
||
<i className="bi bi-check-circle-fill me-1"></i> Save Bedside Vitals
|
||
</button>
|
||
</div>
|
||
|
||
{/* Vitals History Table */}
|
||
<div className="card shadow-sm border-0 rounded-4 overflow-hidden mb-3">
|
||
<div className="card-header py-2 bg-white d-flex justify-content-between align-items-center border-bottom">
|
||
<strong className="text-dark"><i className="bi bi-clock-history me-1 text-danger"></i> Vitals History Records</strong>
|
||
<span className="badge bg-danger-subtle text-danger border border-danger-subtle">{workspaceData.filter(e => e.entry_type === 'vitals').length} Records</span>
|
||
</div>
|
||
<div className="table-responsive">
|
||
<table className="table table-sm table-striped table-hover align-middle mb-0" style={{fontSize: '12px'}}>
|
||
<thead className="table-light">
|
||
<tr>
|
||
<th>Date & Time</th>
|
||
<th>BP (mmHg)</th>
|
||
<th>Pulse</th>
|
||
<th>Temp</th>
|
||
<th>Resp</th>
|
||
<th>SpO2</th>
|
||
<th>Weight</th>
|
||
<th>Staff</th>
|
||
<th>Remarks</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{workspaceData.filter(e => e.entry_type === 'vitals').length === 0 ? (
|
||
<tr>
|
||
<td colSpan="9" className="text-center text-muted py-4">No vitals records entered yet.</td>
|
||
</tr>
|
||
) : (
|
||
workspaceData.filter(e => e.entry_type === 'vitals').map(v => (
|
||
<tr key={v.id}>
|
||
<td className="fw-semibold text-nowrap">{formatDateTime(v.recorded_at)}</td>
|
||
<td><span className="badge bg-light text-dark border">{v.bp_systolic ? `${v.bp_systolic}/${v.bp_diastolic}` : '-'}</span></td>
|
||
<td>{v.pulse_rate ? `${v.pulse_rate} bpm` : '-'}</td>
|
||
<td><span className="badge bg-danger-subtle text-danger">{displayTempF(v.temperature_c)}</span></td>
|
||
<td>{v.resp_rate ? `${v.resp_rate}/min` : '-'}</td>
|
||
<td>{v.spo2 ? `${v.spo2}%` : '-'}</td>
|
||
<td>{v.weight_kg ? `${v.weight_kg} kg` : '-'}</td>
|
||
<td><small className="text-muted">{v.recorded_by || 'Staff'}</small></td>
|
||
<td><small>{v.general_note || '-'}</small></td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB 2: FLUID INTAKE / OUTPUT (CALCULATED DIFFERENCE & HISTORY) */}
|
||
{activeTab === 'fluid' && (() => {
|
||
const fluidList = workspaceData.filter(e => e.entry_type === 'fluid');
|
||
const totalIn = fluidList.filter(e => (e.fluid_direction || '').toLowerCase() === 'intake').reduce((sum, e) => sum + (parseFloat(e.fluid_amount_ml) || 0), 0);
|
||
const totalOut = fluidList.filter(e => (e.fluid_direction || '').toLowerCase() === 'output').reduce((sum, e) => sum + (parseFloat(e.fluid_amount_ml) || 0), 0);
|
||
const netDiff = totalIn - totalOut;
|
||
|
||
const intakeRoutes = ['IV (Intravenous)', 'Oral / Water / Juice', 'Ryles Tube / NG Feed', 'Normal Saline (NS)', 'Dextrose (DNS / RL)', 'Blood / Plasma', 'TPN', 'Other Intake'];
|
||
const outputRoutes = ['Urine / Foley Catheter', 'Drain / Ryle Tube', 'Vomitus', 'Stool / Loose Motion', 'Chest Tube Drain', 'Wound Drain', 'Other Output'];
|
||
|
||
return (
|
||
<div>
|
||
{/* Summary Balance Metric Cards */}
|
||
<div className="row g-2 mb-3">
|
||
<div className="col-4">
|
||
<div className="stat-card-intake text-center shadow-sm">
|
||
<small className="text-success fw-bold d-block mb-1" style={{fontSize: '11px'}}><i className="bi bi-arrow-down-left-circle-fill me-1"></i> Total Intake</small>
|
||
<h5 className="fw-bold text-success mb-0">{totalIn.toLocaleString()} <span style={{fontSize: '11px'}}>ml</span></h5>
|
||
</div>
|
||
</div>
|
||
<div className="col-4">
|
||
<div className="stat-card-output text-center shadow-sm">
|
||
<small className="text-warning fw-bold d-block mb-1" style={{fontSize: '11px'}}><i className="bi bi-arrow-up-right-circle-fill me-1"></i> Total Output</small>
|
||
<h5 className="fw-bold text-warning mb-0">{totalOut.toLocaleString()} <span style={{fontSize: '11px'}}>ml</span></h5>
|
||
</div>
|
||
</div>
|
||
<div className="col-4">
|
||
<div className="stat-card-balance text-center shadow-sm">
|
||
<small className="text-primary fw-bold d-block mb-1" style={{fontSize: '11px'}}><i className="bi bi-calculator me-1"></i> Difference (In-Out)</small>
|
||
<h5 className={`fw-bold mb-0 ${netDiff >= 0 ? 'text-primary' : 'text-danger'}`}>
|
||
{netDiff > 0 ? `+${netDiff.toLocaleString()}` : netDiff.toLocaleString()} <span style={{fontSize: '11px'}}>ml</span>
|
||
</h5>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Add Fluid Entry Form */}
|
||
<div className="card p-3 shadow-sm border-0 mb-3 rounded-4">
|
||
<h6 className="fw-bold mb-3 text-dark"><i className="bi bi-droplet-fill text-primary me-1"></i> Add Fluid Intake / Output Entry</h6>
|
||
|
||
<div className="mb-3">
|
||
<label className="form-label small fw-bold mb-1">Direction</label>
|
||
<div className="d-flex gap-2">
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm flex-fill py-2 fw-bold rounded-3 ${fluidForm.fluid_direction === 'intake' ? 'btn-success text-white shadow-sm' : 'btn-light border text-secondary'}`}
|
||
onClick={() => setFluidForm({...fluidForm, fluid_direction: 'intake', fluid_route: 'IV (Intravenous)'})}
|
||
>
|
||
<i className="bi bi-arrow-down-left-circle me-1"></i> Fluid Intake (In)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm flex-fill py-2 fw-bold rounded-3 ${fluidForm.fluid_direction === 'output' ? 'btn-warning text-dark shadow-sm' : 'btn-light border text-secondary'}`}
|
||
onClick={() => setFluidForm({...fluidForm, fluid_direction: 'output', fluid_route: 'Urine / Foley Catheter'})}
|
||
>
|
||
<i className="bi bi-arrow-up-right-circle me-1"></i> Fluid Output (Out)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="row g-2 mb-3">
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small fw-bold mb-1">Route / Site</label>
|
||
<select
|
||
className="form-select form-select-sm"
|
||
value={fluidForm.fluid_route}
|
||
onChange={e => setFluidForm({...fluidForm, fluid_route: e.target.value})}
|
||
>
|
||
{(fluidForm.fluid_direction === 'intake' ? intakeRoutes : outputRoutes).map(r => (
|
||
<option key={r} value={r}>{r}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small fw-bold mb-1">Amount (ml) <span className="text-danger">*</span></label>
|
||
<input
|
||
type="number"
|
||
className="form-control form-control-sm text-center fw-bold fs-6"
|
||
placeholder="e.g. 500"
|
||
value={fluidForm.fluid_amount_ml}
|
||
onChange={e => setFluidForm({...fluidForm, fluid_amount_ml: e.target.value})}
|
||
/>
|
||
</div>
|
||
<div className="col-12">
|
||
<label className="form-label small fw-bold mb-1">Observation / Note</label>
|
||
<input
|
||
type="text"
|
||
className="form-control form-control-sm"
|
||
placeholder="e.g. Clear urine, Normal fluid color, IV site intact"
|
||
value={fluidForm.general_note}
|
||
onChange={e => setFluidForm({...fluidForm, general_note: e.target.value})}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="btn btn-primary fw-bold py-2 w-100 rounded-3 shadow-sm"
|
||
onClick={() => {
|
||
if (!fluidForm.fluid_amount_ml) {
|
||
alert('Please enter fluid amount in ml.');
|
||
return;
|
||
}
|
||
handleSaveIpdEntry('fluid', fluidForm);
|
||
}}
|
||
>
|
||
<i className="bi bi-plus-circle-fill me-1"></i> Save Fluid Entry
|
||
</button>
|
||
</div>
|
||
|
||
{/* Fluid History Table */}
|
||
<div className="card shadow-sm border-0 rounded-4 overflow-hidden mb-3">
|
||
<div className="card-header py-2 bg-white d-flex justify-content-between align-items-center border-bottom">
|
||
<strong className="text-dark"><i className="bi bi-clock-history me-1 text-primary"></i> Fluid Intake & Output Log</strong>
|
||
<span className="badge bg-primary-subtle text-primary border border-primary-subtle">{fluidList.length} Records</span>
|
||
</div>
|
||
<div className="table-responsive">
|
||
<table className="table table-sm table-striped table-hover align-middle mb-0" style={{fontSize: '12px'}}>
|
||
<thead className="table-light">
|
||
<tr>
|
||
<th>Date & Time</th>
|
||
<th>Direction</th>
|
||
<th>Route</th>
|
||
<th>Amount (ml)</th>
|
||
<th>Observation / Note</th>
|
||
<th>Staff</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{fluidList.length === 0 ? (
|
||
<tr>
|
||
<td colSpan="6" className="text-center text-muted py-4">No fluid entries recorded yet.</td>
|
||
</tr>
|
||
) : (
|
||
fluidList.map(fl => (
|
||
<tr key={fl.id}>
|
||
<td className="fw-semibold text-nowrap">{formatDateTime(fl.recorded_at)}</td>
|
||
<td>
|
||
<span className={`badge ${fl.fluid_direction === 'intake' ? 'bg-success' : 'bg-warning text-dark'} text-uppercase`}>
|
||
{fl.fluid_direction === 'intake' ? '↓ Intake' : '↑ Output'}
|
||
</span>
|
||
</td>
|
||
<td className="fw-semibold">{fl.fluid_route || '-'}</td>
|
||
<td className="fw-bold">{parseFloat(fl.fluid_amount_ml || 0).toLocaleString()} ml</td>
|
||
<td><small>{fl.general_note || '-'}</small></td>
|
||
<td><small className="text-muted">{fl.recorded_by || 'Staff'}</small></td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* TAB 3: TREATMENT & DOCTOR ORDERS REFERENCE */}
|
||
{activeTab === 'treatment' && (() => {
|
||
const allTreatments = workspaceData.filter(e => e.entry_type === 'treatment');
|
||
const doctorOrders = allTreatments.filter(e =>
|
||
(e.recorded_by && e.recorded_by.toLowerCase().startsWith('dr.')) ||
|
||
(e.treatment_text && e.treatment_text.includes('[Doctor Note]'))
|
||
);
|
||
const latestDocOrder = doctorOrders.length > 0 ? doctorOrders[0] : null;
|
||
|
||
const nurseTreatments = allTreatments.filter(e =>
|
||
!(e.recorded_by && e.recorded_by.toLowerCase().startsWith('dr.')) &&
|
||
!(e.treatment_text && e.treatment_text.includes('[Doctor Note]'))
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
{/* DOCTOR TREATMENT REFERENCE CARD */}
|
||
<div className="card shadow-sm border-0 mb-3 rounded-4 overflow-hidden" style={{border: '1.5px solid #818cf8'}}>
|
||
<div className="card-header py-2 text-white d-flex justify-content-between align-items-center" style={{background: 'linear-gradient(135deg, #3730a3 0%, #4f46e5 100%)'}}>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<i className="bi bi-file-earmark-medical-fill"></i>
|
||
<strong className="small">Doctor's Prescribed Treatment & Orders</strong>
|
||
</div>
|
||
<span className="badge bg-white text-dark small">{doctorOrders.length} Doctor Orders</span>
|
||
</div>
|
||
|
||
<div className="card-body p-3 bg-light">
|
||
{latestDocOrder ? (
|
||
<div>
|
||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||
<span className="badge bg-primary px-2 py-1">
|
||
<i className="bi bi-person-badge me-1"></i> {latestDocOrder.recorded_by}
|
||
</span>
|
||
<small className="text-muted fw-semibold">{formatDateTime(latestDocOrder.recorded_at)}</small>
|
||
</div>
|
||
|
||
<div className="p-3 bg-white rounded-3 border mb-2 shadow-sm">
|
||
{renderNoteContent(latestDocOrder)}
|
||
</div>
|
||
|
||
<button
|
||
className="btn btn-sm btn-outline-primary fw-bold w-100 rounded-pill py-2"
|
||
onClick={() => {
|
||
const cleanText = (latestDocOrder.treatment_text || '')
|
||
.replace(/\[IMAGE_ATTACHMENT:.*?\]/, '')
|
||
.replace('[Doctor Note] ', '')
|
||
.trim();
|
||
setTreatmentForm({
|
||
...treatmentForm,
|
||
treatment_text: `Administered as prescribed: ${cleanText}`
|
||
});
|
||
}}
|
||
>
|
||
<i className="bi bi-clipboard-check-fill me-1"></i> Follow & Administer This Order
|
||
</button>
|
||
|
||
{doctorOrders.length > 1 && (
|
||
<div className="mt-3">
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-light border w-100 text-secondary"
|
||
onClick={() => setShowAllDoctorOrders(!showAllDoctorOrders)}
|
||
>
|
||
<i className={`bi bi-chevron-${showAllDoctorOrders ? 'up' : 'down'} me-1`}></i>
|
||
{showAllDoctorOrders ? 'Hide Previous Doctor Notes' : `View All ${doctorOrders.length} Doctor Notes`}
|
||
</button>
|
||
|
||
{showAllDoctorOrders && (
|
||
<div className="mt-2 d-flex flex-column gap-2">
|
||
{doctorOrders.slice(1).map(docOrd => (
|
||
<div key={docOrd.id} className="p-2.5 bg-white rounded-3 border">
|
||
<div className="d-flex justify-content-between align-items-center mb-1">
|
||
<strong className="text-primary small">{docOrd.recorded_by}</strong>
|
||
<small className="text-muted" style={{fontSize: '11px'}}>{formatDateTime(docOrd.recorded_at)}</small>
|
||
</div>
|
||
{renderNoteContent(docOrd)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-center text-muted py-3">
|
||
<i className="bi bi-clipboard-x fs-2 text-secondary opacity-50 d-block mb-1"></i>
|
||
<small>No doctor orders or clinical prescriptions recorded yet.</small>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* NURSING TREATMENT GIVEN FORM */}
|
||
<div className="card p-3 shadow-sm border-0 mb-3 rounded-4">
|
||
<h6 className="fw-bold mb-3 text-dark"><i className="bi bi-capsule text-success me-1"></i> Log Nursing Treatment Given / Medication Administered</h6>
|
||
|
||
<div className="mb-3">
|
||
<label className="form-label small fw-bold mb-1">Treatment / Medication Administered <span className="text-danger">*</span></label>
|
||
<textarea
|
||
className="form-control rounded-3"
|
||
rows="3"
|
||
placeholder="e.g. Inj. Ceftriaxone 1g IV given slowly, Tab Pantop 40mg given orally..."
|
||
value={treatmentForm.treatment_text}
|
||
onChange={e => setTreatmentForm({...treatmentForm, treatment_text: e.target.value})}
|
||
></textarea>
|
||
</div>
|
||
|
||
<div className="mb-3">
|
||
<label className="form-label small fw-bold mb-1">Observation / Patient Response</label>
|
||
<input
|
||
type="text"
|
||
className="form-control form-control-sm"
|
||
placeholder="e.g. Tolerated well, No adverse reaction, IV site patent"
|
||
value={treatmentForm.general_note}
|
||
onChange={e => setTreatmentForm({...treatmentForm, general_note: e.target.value})}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
className="btn btn-success fw-bold py-2 w-100 rounded-3 shadow-sm"
|
||
onClick={() => {
|
||
if (!treatmentForm.treatment_text.trim()) {
|
||
alert('Please enter treatment or medication administered.');
|
||
return;
|
||
}
|
||
handleSaveIpdEntry('treatment', treatmentForm);
|
||
}}
|
||
>
|
||
<i className="bi bi-send-check-fill me-1"></i> Log Treatment Administration
|
||
</button>
|
||
</div>
|
||
|
||
{/* NURSING TREATMENT HISTORY LOG WITH TIME */}
|
||
<div className="card shadow-sm border-0 rounded-4 overflow-hidden mb-3">
|
||
<div className="card-header py-2 bg-white d-flex justify-content-between align-items-center border-bottom">
|
||
<strong className="text-dark"><i className="bi bi-clock-history me-1 text-success"></i> Nursing Treatment Given History</strong>
|
||
<span className="badge bg-success-subtle text-success border border-success-subtle">{nurseTreatments.length} Records</span>
|
||
</div>
|
||
<div className="table-responsive">
|
||
<table className="table table-sm table-striped table-hover align-middle mb-0" style={{fontSize: '12px'}}>
|
||
<thead className="table-light">
|
||
<tr>
|
||
<th style={{width: '140px'}}>Time Administered</th>
|
||
<th>Treatment / Medication Given</th>
|
||
<th>Observation & Remarks</th>
|
||
<th style={{width: '120px'}}>Staff Nurse</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{nurseTreatments.length === 0 ? (
|
||
<tr>
|
||
<td colSpan="4" className="text-center text-muted py-4">No nursing treatments logged yet.</td>
|
||
</tr>
|
||
) : (
|
||
nurseTreatments.map(tr => (
|
||
<tr key={tr.id}>
|
||
<td className="fw-semibold text-nowrap">{formatDateTime(tr.recorded_at)}</td>
|
||
<td>
|
||
<div className="fw-bold text-dark">{renderNoteContent(tr)}</div>
|
||
</td>
|
||
<td><small className="text-muted">{tr.general_note || '-'}</small></td>
|
||
<td>
|
||
<span className="badge bg-secondary-subtle text-secondary border">
|
||
{tr.recorded_by || 'Staff Nurse'}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{/* -------------------------------------------------------------
|
||
MODAL: FILL OPD VITALS
|
||
------------------------------------------------------------- */}
|
||
{activeOpdForVitals && (
|
||
<div className="modal show d-block" style={{backgroundColor: 'rgba(0,0,0,0.5)'}}>
|
||
<div className="modal-dialog modal-dialog-centered">
|
||
<div className="modal-content rounded-4 border-0 shadow-lg">
|
||
<div className="modal-header bg-success text-white">
|
||
<h6 className="modal-title fw-bold">
|
||
<i className="bi bi-heart-pulse-fill me-2"></i> Fill OPD Patient Vitals
|
||
</h6>
|
||
<button className="btn-close btn-close-white" onClick={() => setActiveOpdForVitals(null)}></button>
|
||
</div>
|
||
<div className="modal-body p-3">
|
||
<div className="alert alert-light border py-2 mb-3 small">
|
||
<strong>{activeOpdForVitals.patient_display_name}</strong> ({activeOpdForVitals.age_display} • {activeOpdForVitals.gender_label})
|
||
<br /><small className="text-muted">UHID: {activeOpdForVitals.uhid} | OPD Code: {activeOpdForVitals.opd_code}</small>
|
||
</div>
|
||
|
||
<div className="row g-2">
|
||
<div className="col-6">
|
||
<label className="form-label small fw-bold mb-1">BP Sys/Dia</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" className="form-control text-center" placeholder="120" value={opdVitalsForm.bp_systolic} onChange={e => setOpdVitalsForm({...opdVitalsForm, bp_systolic: e.target.value})} />
|
||
<span className="input-group-text">/</span>
|
||
<input type="number" className="form-control text-center" placeholder="80" value={opdVitalsForm.bp_diastolic} onChange={e => setOpdVitalsForm({...opdVitalsForm, bp_diastolic: e.target.value})} />
|
||
</div>
|
||
</div>
|
||
<div className="col-6">
|
||
<label className="form-label small fw-bold mb-1">Pulse Rate (bpm)</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="72" value={opdVitalsForm.pulse} onChange={e => setOpdVitalsForm({...opdVitalsForm, pulse: e.target.value})} />
|
||
</div>
|
||
<div className="col-6">
|
||
<label className="form-label small fw-bold mb-1">SpO2 %</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="98" value={opdVitalsForm.spo2} onChange={e => setOpdVitalsForm({...opdVitalsForm, spo2: e.target.value})} />
|
||
</div>
|
||
<div className="col-6">
|
||
<label className="form-label small fw-bold mb-1">Temp °F</label>
|
||
<input type="number" step="0.1" className="form-control form-control-sm text-center" placeholder="98.6" value={opdVitalsForm.temp} onChange={e => setOpdVitalsForm({...opdVitalsForm, temp: e.target.value})} />
|
||
</div>
|
||
<div className="col-6">
|
||
<label className="form-label small fw-bold mb-1">Weight (kg)</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="70" value={opdVitalsForm.weight} onChange={e => setOpdVitalsForm({...opdVitalsForm, weight: e.target.value})} />
|
||
</div>
|
||
<div className="col-6">
|
||
<label className="form-label small fw-bold mb-1">Height (cm)</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="165" value={opdVitalsForm.height} onChange={e => setOpdVitalsForm({...opdVitalsForm, height: e.target.value})} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer p-2 bg-light">
|
||
<button className="btn btn-secondary btn-sm" onClick={() => setActiveOpdForVitals(null)}>Cancel</button>
|
||
<button className="btn btn-success btn-sm fw-bold px-3" onClick={handleSaveOpdVitals}>
|
||
<i className="bi bi-check-circle-fill me-1"></i> Save Vitals for Doctor
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* -------------------------------------------------------------
|
||
MODAL: SCAN / PHOTO UPLOAD FOR PAPER DOCUMENTS
|
||
------------------------------------------------------------- */}
|
||
{scanTarget && (
|
||
<div className="modal show d-block" style={{backgroundColor: 'rgba(0,0,0,0.5)'}}>
|
||
<div className="modal-dialog modal-dialog-centered">
|
||
<div className="modal-content rounded-4 border-0 shadow-lg">
|
||
<div className="modal-header bg-primary text-white">
|
||
<h6 className="modal-title fw-bold">
|
||
<i className="bi bi-camera-fill me-2"></i> Scan / Photo Paper Document
|
||
</h6>
|
||
<button className="btn-close btn-close-white" onClick={() => setScanTarget(null)}></button>
|
||
</div>
|
||
<div className="modal-body p-3">
|
||
<div className="mb-3">
|
||
<label className="form-label small fw-bold">Document Type / Category</label>
|
||
<select className="form-select form-select-sm" value={scanDocCategory} onChange={e => setScanDocCategory(e.target.value)}>
|
||
<option value="Paper Document">Paper Document (General)</option>
|
||
<option value="Vitals Sheet">Vitals Sheet / Chart</option>
|
||
<option value="Lab Test Report">Lab / Pathology Report</option>
|
||
<option value="Handwritten Doctor Note">Handwritten Doctor Note</option>
|
||
<option value="Consent Form">Signed Consent Form</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="mb-3 text-center">
|
||
<label className="btn btn-primary btn-lg w-100 py-3 fw-bold rounded-3 shadow-sm">
|
||
<i className="bi bi-camera me-2"></i> Take Photo / Choose File
|
||
<input type="file" accept="image/*" capture="environment" className="d-none" onChange={handleScanImageCapture} />
|
||
</label>
|
||
</div>
|
||
|
||
{scanImagePreview && (
|
||
<div className="position-relative p-2 border rounded-3 bg-light text-center">
|
||
<span className="badge bg-success position-absolute top-0 start-0 m-2"><i className="bi bi-check-circle me-1"></i> Document Photo Captured</span>
|
||
<img src={scanImagePreview} alt="Document Scan Preview" className="img-fluid rounded-3 border shadow-sm" style={{maxHeight: '220px', objectFit: 'contain'}} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="modal-footer p-2 bg-light">
|
||
<button className="btn btn-secondary btn-sm" onClick={() => setScanTarget(null)}>Cancel</button>
|
||
<button className="btn btn-success btn-sm fw-bold px-3" onClick={handleSaveScanDocument} disabled={!scanImageBase64}>
|
||
<i className="bi bi-cloud-upload-fill me-1"></i> Save Document to Chart
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* DOCUMENT PHOTO CROPPER MODAL */}
|
||
{cropSrc && (
|
||
<div className="modal show d-block" style={{backgroundColor: 'rgba(0,0,0,0.85)', zIndex: 10000}}>
|
||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||
<div className="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||
<div className="modal-header bg-dark text-white py-2">
|
||
<h6 className="modal-title fw-bold mb-0">
|
||
<i className="bi bi-crop me-2 text-success"></i> Crop Document Photo
|
||
</h6>
|
||
<button className="btn-close btn-close-white" onClick={cancelCrop}></button>
|
||
</div>
|
||
<div className="modal-body p-2 text-center bg-black" style={{maxHeight: '65vh', overflow: 'hidden'}}>
|
||
<div style={{maxHeight: '60vh', width: '100%'}}>
|
||
<img ref={imageRef} src={cropSrc} alt="Crop Target" style={{maxWidth: '100%', display: 'block'}} />
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer p-2 bg-dark d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<div className="d-flex gap-1">
|
||
<button className="btn btn-sm btn-outline-light px-2" onClick={() => cropper && cropper.rotate(-90)} title="Rotate Left 90°">
|
||
<i className="bi bi-arrow-counterclockwise"></i> 90°
|
||
</button>
|
||
<button className="btn btn-sm btn-outline-light px-2" onClick={() => cropper && cropper.rotate(90)} title="Rotate Right 90°">
|
||
<i className="bi bi-arrow-clockwise"></i> 90°
|
||
</button>
|
||
<button className="btn btn-sm btn-outline-light px-2" onClick={() => cropper && cropper.reset()} title="Reset Crop Box">
|
||
<i className="bi bi-aspect-ratio"></i> Reset
|
||
</button>
|
||
</div>
|
||
<div className="d-flex gap-2">
|
||
<button className="btn btn-sm btn-secondary" onClick={cancelCrop}>Cancel</button>
|
||
<button className="btn btn-sm btn-success fw-bold px-3" onClick={applyCrop}>
|
||
<i className="bi bi-check-lg me-1"></i> Apply Crop & Save Photo
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* -------------------------------------------------------------
|
||
FLOATING BOTTOM QUICK ACTION BAR (DOCK)
|
||
------------------------------------------------------------- */}
|
||
<div className="bottom-dock-container">
|
||
<button
|
||
className="bottom-dock-btn btn-scan"
|
||
onClick={() => openPatientSearch('scan')}
|
||
title="Scan Barcode / QR or Search Patient"
|
||
>
|
||
<i className="bi bi-qr-code-scan fs-5"></i>
|
||
<span>Scan / Search</span>
|
||
</button>
|
||
|
||
<button
|
||
className="bottom-dock-btn btn-vitals"
|
||
onClick={() => {
|
||
if (activeBed) {
|
||
openWellnessModal({ id: activeBed.p_id || activeBed.patient_id, p_code: activeBed.uhid, fullName: activeBed.patient_name, abha_id: activeBed.abha_id, gender: activeBed.gender });
|
||
} else {
|
||
openPatientSearch('type');
|
||
}
|
||
}}
|
||
title="Add ABDM M2 Wellness Vitals"
|
||
>
|
||
<i className="bi bi-heart-pulse-fill fs-5"></i>
|
||
<span>ABDM Vitals</span>
|
||
</button>
|
||
|
||
<button
|
||
className="bottom-dock-btn btn-doc"
|
||
onClick={() => {
|
||
if (activeBed) {
|
||
openDocModal({ id: activeBed.p_id || activeBed.patient_id, p_code: activeBed.uhid, fullName: activeBed.patient_name, abha_id: activeBed.abha_id });
|
||
} else {
|
||
openPatientSearch('type');
|
||
}
|
||
}}
|
||
title="Add ABDM M2 Health Document (Scan/Photo)"
|
||
>
|
||
<i className="bi bi-file-earmark-medical-fill fs-5"></i>
|
||
<span>ABDM Health Doc</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* -------------------------------------------------------------
|
||
MODAL 1: SEARCH PATIENT BY BARCODE, QR CODE & TEXT (ABDM M2)
|
||
------------------------------------------------------------- */}
|
||
{showPatientSearchModal && (
|
||
<div className="modal show d-block" style={{backgroundColor: 'rgba(0,0,0,0.65)', zIndex: 1050}}>
|
||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||
<div className="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||
<div className="modal-header bg-dark text-white py-2.5">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<i className="bi bi-upc-scan fs-5 text-primary"></i>
|
||
<div>
|
||
<h6 className="modal-title fw-bold mb-0">Search Patient by Barcode, QR & Name</h6>
|
||
<small className="text-white-50" style={{fontSize: '11px'}}>Scan Hospital Card Barcode, ABHA QR, or search registered patients</small>
|
||
</div>
|
||
</div>
|
||
<button className="btn-close btn-close-white" onClick={closePatientSearch}></button>
|
||
</div>
|
||
|
||
<div className="modal-body p-3">
|
||
{/* Segmented Mode Selector */}
|
||
<div className="segmented-tabs mb-3">
|
||
<button
|
||
className={`segmented-tab ${searchModalTab === 'scan' ? 'active-blue' : ''}`}
|
||
onClick={() => {
|
||
setSearchModalTab('scan');
|
||
startBarcodeQrScanner();
|
||
}}
|
||
>
|
||
<i className="bi bi-camera-video-fill me-1"></i> Camera Scanner (Barcode & QR)
|
||
</button>
|
||
<button
|
||
className={`segmented-tab ${searchModalTab === 'type' ? 'active-blue' : ''}`}
|
||
onClick={() => {
|
||
setSearchModalTab('type');
|
||
stopBarcodeQrScanner();
|
||
}}
|
||
>
|
||
<i className="bi bi-search me-1"></i> Manual Search (UHID / Mobile / ABHA)
|
||
</button>
|
||
</div>
|
||
|
||
{/* CAMERA SCANNER TAB */}
|
||
{searchModalTab === 'scan' && (
|
||
<div className="text-center mb-3">
|
||
<div className="qr-scanner-wrapper mx-auto mb-2" style={{maxWidth: '420px'}}>
|
||
<div id="patient-qr-reader" style={{width: '100%'}}></div>
|
||
</div>
|
||
|
||
{scannerError ? (
|
||
<div className="alert alert-warning py-2 px-3 small d-inline-block">
|
||
<i className="bi bi-exclamation-triangle-fill me-1"></i> {scannerError}
|
||
</div>
|
||
) : (
|
||
<small className="text-muted d-block">
|
||
<i className="bi bi-info-circle me-1"></i> Point camera at <strong>Patient Card Barcode</strong> or <strong>ABHA QR Code</strong> to scan instantly.
|
||
</small>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* SEARCH INPUT */}
|
||
<div className="input-group mb-3">
|
||
<span className="input-group-text bg-white border-end-0">
|
||
<i className="bi bi-search text-muted"></i>
|
||
</span>
|
||
<input
|
||
type="text"
|
||
className="form-control border-start-0 ps-0"
|
||
placeholder="Search by UHID (P...), ABHA No, Mobile (98...), Name..."
|
||
value={patientSearchQuery}
|
||
onChange={e => {
|
||
setPatientSearchQuery(e.target.value);
|
||
executePatientSearch(e.target.value);
|
||
}}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') executePatientSearch(patientSearchQuery);
|
||
}}
|
||
/>
|
||
{patientSearchQuery && (
|
||
<button className="btn btn-outline-secondary" onClick={() => { setPatientSearchQuery(''); setPatientSearchResults([]); }}>
|
||
<i className="bi bi-x"></i>
|
||
</button>
|
||
)}
|
||
<button className="btn btn-primary fw-bold px-3" onClick={() => executePatientSearch(patientSearchQuery)}>
|
||
{searchingPatient ? <span className="spinner-border spinner-border-sm"></span> : 'Search'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* SEARCH RESULTS LIST */}
|
||
<div>
|
||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||
<strong className="small text-secondary text-uppercase">
|
||
Matched Patients ({patientSearchResults.length})
|
||
</strong>
|
||
{searchingPatient && <span className="badge bg-primary-subtle text-primary">Searching...</span>}
|
||
</div>
|
||
|
||
{patientSearchResults.length === 0 ? (
|
||
<div className="text-center py-4 bg-light rounded-3 border">
|
||
<i className="bi bi-person-badge fs-2 text-muted opacity-50 d-block mb-1"></i>
|
||
<p className="small text-muted mb-0">
|
||
{patientSearchQuery ? 'No patients found matching this query.' : 'Type patient name, UHID, ABHA number or scan barcode above.'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="d-flex flex-column gap-2" style={{maxHeight: '40vh', overflowY: 'auto'}}>
|
||
{patientSearchResults.map(pat => (
|
||
<div key={pat.id} className="patient-search-result-card">
|
||
<div className="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||
<div>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<h6 className="fw-bold mb-0 text-dark">{pat.fullName}</h6>
|
||
<span className="badge bg-light text-dark border" style={{fontSize: '11px'}}>
|
||
{pat.gender === '1' || pat.gender === 1 ? 'Male' : (pat.gender === '2' || pat.gender === 2 ? 'Female' : 'Other')}
|
||
{pat.dob ? ` • DOB: ${pat.dob}` : ''}
|
||
</span>
|
||
</div>
|
||
<div className="d-flex flex-wrap gap-2 mt-1 small">
|
||
<span className="text-danger fw-semibold">UHID: {pat.p_code}</span>
|
||
{pat.mphone1 && <span className="text-muted"><i className="bi bi-telephone me-1"></i>{pat.mphone1}</span>}
|
||
{pat.abha_id && (
|
||
<span className="badge bg-success-subtle text-success border border-success-subtle">
|
||
<i className="bi bi-shield-check me-1"></i>ABHA: {pat.abha_id}
|
||
</span>
|
||
)}
|
||
{pat.abha_address && (
|
||
<span className="badge bg-primary-subtle text-primary border border-primary-subtle">
|
||
{pat.abha_address}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{pat.ipd_admission && (
|
||
<div className="mt-1">
|
||
<span className="badge bg-primary">
|
||
<i className="bi bi-hospital me-1"></i> Admitted IPD: {pat.ipd_admission.ipd_code} (Bed {pat.ipd_admission.bed_number || pat.ipd_admission.bed_no})
|
||
</span>
|
||
</div>
|
||
)}
|
||
{pat.opd_today && (
|
||
<div className="mt-1">
|
||
<span className="badge bg-success">
|
||
<i className="bi bi-calendar-check me-1"></i> OPD Today: Token #{pat.opd_today.token_no} ({pat.opd_today.doctor_name || 'Doctor'})
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 1-Click Action Buttons */}
|
||
<div className="d-flex flex-wrap gap-1.5 align-items-center">
|
||
<button
|
||
className="btn btn-sm btn-success fw-bold rounded-3 px-2 py-1.5"
|
||
onClick={() => openWellnessModal(pat)}
|
||
title="Add ABDM M2 Wellness Vitals"
|
||
>
|
||
<i className="bi bi-heart-pulse-fill me-1"></i> Add Vitals
|
||
</button>
|
||
|
||
<button
|
||
className="btn btn-sm btn-primary fw-bold rounded-3 px-2 py-1.5"
|
||
onClick={() => openDocModal(pat)}
|
||
title="Add ABDM M2 Health Document"
|
||
>
|
||
<i className="bi bi-file-earmark-medical-fill me-1"></i> Add Doc
|
||
</button>
|
||
|
||
<button
|
||
className="btn btn-sm btn-outline-secondary rounded-3 px-2 py-1.5"
|
||
onClick={() => openWellnessHistory(pat)}
|
||
title="View Vitals History"
|
||
>
|
||
<i className="bi bi-clock-history"></i>
|
||
</button>
|
||
|
||
{pat.ipd_admission && (
|
||
<button
|
||
className="btn btn-sm btn-dark rounded-3 px-2 py-1.5"
|
||
onClick={() => {
|
||
closePatientSearch();
|
||
openWorkspace({ ipd_id: pat.ipd_admission.ipd_id, patient_name: pat.fullName, uhid: pat.p_code, ipd_code: pat.ipd_admission.ipd_code, bed_number: pat.ipd_admission.bed_number, ward_name: pat.ipd_admission.ward_name, status: 'occupied' });
|
||
}}
|
||
title="Go to IPD Bedside Care"
|
||
>
|
||
<i className="bi bi-arrow-right-circle"></i> IPD Bed
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="modal-footer p-2 bg-light d-flex justify-content-between">
|
||
<small className="text-muted"><i className="bi bi-shield-lock me-1"></i> ABDM M2 Standard Compliance</small>
|
||
<button className="btn btn-secondary btn-sm px-3" onClick={closePatientSearch}>Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* -------------------------------------------------------------
|
||
MODAL 2: ABDM M2 WELLNESS & VITALS ENTRY (COMPREHENSIVE SUITE)
|
||
------------------------------------------------------------- */}
|
||
{activeWellnessPatient && (
|
||
<div className="modal show d-block" style={{backgroundColor: 'rgba(0,0,0,0.65)', zIndex: 1060}}>
|
||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||
<div className="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||
<div className="modal-header bg-success text-white py-2.5">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<i className="bi bi-heart-pulse-fill fs-5"></i>
|
||
<div>
|
||
<h6 className="modal-title fw-bold mb-0">Record ABDM M2 Wellness & Vitals</h6>
|
||
<small className="text-white-50" style={{fontSize: '11px'}}>NRCES FHIR R4 Compliant Comprehensive Health & Wellness Suite</small>
|
||
</div>
|
||
</div>
|
||
<button className="btn-close btn-close-white" onClick={() => setActiveWellnessPatient(null)}></button>
|
||
</div>
|
||
|
||
<div className="modal-body p-3">
|
||
{/* Patient Context Banner */}
|
||
<div className="p-2.5 rounded-3 bg-light border mb-3">
|
||
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<div>
|
||
<strong className="fs-6 text-dark me-2">{activeWellnessPatient.fullName || activeWellnessPatient.p_fname}</strong>
|
||
<span className="badge bg-secondary me-1">{activeWellnessPatient.gender ? activeWellnessPatient.gender.toUpperCase() : 'PATIENT'}</span>
|
||
{activeWellnessPatient.age && <span className="badge bg-light text-dark border me-1">{activeWellnessPatient.age} Yrs</span>}
|
||
<div className="small text-muted mt-0.5">
|
||
UHID: <span className="text-danger fw-bold">{activeWellnessPatient.p_code || activeWellnessPatient.uhid}</span>
|
||
{activeWellnessPatient.mphone1 && ` • Phone: ${activeWellnessPatient.mphone1}`}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
{activeWellnessPatient.abha_id ? (
|
||
<span className="badge bg-success">
|
||
<i className="bi bi-shield-check me-1"></i> ABHA: {activeWellnessPatient.abha_id}
|
||
</span>
|
||
) : (
|
||
<span className="badge bg-secondary-subtle text-secondary border">
|
||
<i className="bi bi-shield-exclamation me-1"></i> ABHA Not Linked
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Category Tab Switcher */}
|
||
<ul className="nav nav-pills nav-fill bg-light p-1 rounded-3 mb-3 border small">
|
||
<li className="nav-item">
|
||
<button
|
||
type="button"
|
||
className={`nav-link py-1.5 px-2 fw-semibold d-flex align-items-center justify-content-center gap-1 ${wellnessModalTab === 'vitals' ? 'active shadow-sm' : 'text-secondary'}`}
|
||
onClick={() => setWellnessModalTab('vitals')}
|
||
>
|
||
<i className="bi bi-heart-pulse-fill text-danger"></i>
|
||
<span>Vitals & Triage</span>
|
||
{(wellnessForm.bp_systolic || wellnessForm.pulse_rate || wellnessForm.temperature_f) && (
|
||
<span className="badge rounded-pill bg-success p-1 ms-1" style={{fontSize: '7px'}}>✓</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
<li className="nav-item">
|
||
<button
|
||
type="button"
|
||
className={`nav-link py-1.5 px-2 fw-semibold d-flex align-items-center justify-content-center gap-1 ${wellnessModalTab === 'sugar' ? 'active shadow-sm' : 'text-secondary'}`}
|
||
onClick={() => setWellnessModalTab('sugar')}
|
||
>
|
||
<i className="bi bi-droplet-half text-danger"></i>
|
||
<span>Sugar & Labs</span>
|
||
{(wellnessForm.sugar_random || wellnessForm.sugar_fasting || wellnessForm.sugar_pp || wellnessForm.hba1c || wellnessForm.hemoglobin) && (
|
||
<span className="badge rounded-pill bg-success p-1 ms-1" style={{fontSize: '7px'}}>✓</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
<li className="nav-item">
|
||
<button
|
||
type="button"
|
||
className={`nav-link py-1.5 px-2 fw-semibold d-flex align-items-center justify-content-center gap-1 ${wellnessModalTab === 'activity' ? 'active shadow-sm' : 'text-secondary'}`}
|
||
onClick={() => setWellnessModalTab('activity')}
|
||
>
|
||
<i className="bi bi-person-bounding-box text-primary"></i>
|
||
<span>Waist & Activity</span>
|
||
{(wellnessForm.waist_circumference_cm || wellnessForm.hip_circumference_cm || wellnessForm.daily_steps || wellnessForm.sleep_hours) && (
|
||
<span className="badge rounded-pill bg-success p-1 ms-1" style={{fontSize: '7px'}}>✓</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
<li className="nav-item">
|
||
<button
|
||
type="button"
|
||
className={`nav-link py-1.5 px-2 fw-semibold d-flex align-items-center justify-content-center gap-1 ${wellnessModalTab === 'lifestyle' ? 'active shadow-sm' : 'text-secondary'}`}
|
||
onClick={() => setWellnessModalTab('lifestyle')}
|
||
>
|
||
<i className="bi bi-apple text-success"></i>
|
||
<span>Lifestyle & Notes</span>
|
||
{(wellnessForm.diet_type || wellnessForm.tobacco_status || wellnessForm.diet_lifestyle_note || wellnessForm.general_advice) && (
|
||
<span className="badge rounded-pill bg-success p-1 ms-1" style={{fontSize: '7px'}}>✓</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
{String(activeWellnessPatient.gender || '').toLowerCase() !== 'male' && (
|
||
<li className="nav-item">
|
||
<button
|
||
type="button"
|
||
className={`nav-link py-1.5 px-2 fw-semibold d-flex align-items-center justify-content-center gap-1 ${wellnessModalTab === 'women' ? 'active shadow-sm' : 'text-secondary'}`}
|
||
onClick={() => setWellnessModalTab('women')}
|
||
>
|
||
<i className="bi bi-gender-female text-warning"></i>
|
||
<span>Women's Health</span>
|
||
{(wellnessForm.women_lmp || wellnessForm.women_pregnancy_status) && (
|
||
<span className="badge rounded-pill bg-success p-1 ms-1" style={{fontSize: '7px'}}>✓</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
)}
|
||
</ul>
|
||
|
||
{/* TAB 1: VITALS & TRIAGE */}
|
||
{wellnessModalTab === 'vitals' && (
|
||
<div className="row g-2.5 mb-2">
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">BP Sys/Dia (mmHg)</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" className="form-control text-center" placeholder="120" value={wellnessForm.bp_systolic} onChange={e => setWellnessForm({...wellnessForm, bp_systolic: e.target.value})} />
|
||
<span className="input-group-text px-1">/</span>
|
||
<input type="number" className="form-control text-center" placeholder="80" value={wellnessForm.bp_diastolic} onChange={e => setWellnessForm({...wellnessForm, bp_diastolic: e.target.value})} />
|
||
</div>
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Pulse Rate (bpm)</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="72" value={wellnessForm.pulse_rate} onChange={e => setWellnessForm({...wellnessForm, pulse_rate: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">SpO2 %</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="98" value={wellnessForm.spo2} onChange={e => setWellnessForm({...wellnessForm, spo2: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Temp °F</label>
|
||
<input type="number" step="0.1" className="form-control form-control-sm text-center" placeholder="98.6" value={wellnessForm.temperature_f} onChange={e => setWellnessForm({...wellnessForm, temperature_f: e.target.value})} />
|
||
</div>
|
||
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Resp Rate (/min)</label>
|
||
<input type="number" className="form-control form-control-sm text-center" placeholder="18" value={wellnessForm.resp_rate} onChange={e => setWellnessForm({...wellnessForm, resp_rate: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Height (cm)</label>
|
||
<input type="number" step="0.1" className="form-control form-control-sm text-center" placeholder="165" value={wellnessForm.height_cm} onChange={e => setWellnessForm({...wellnessForm, height_cm: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Weight (kg)</label>
|
||
<input type="number" step="0.1" className="form-control form-control-sm text-center" placeholder="70" value={wellnessForm.weight_kg} onChange={e => setWellnessForm({...wellnessForm, weight_kg: e.target.value})} />
|
||
</div>
|
||
<div className="col-6 col-md-3">
|
||
<label className="form-label small mb-1 fw-bold">Calculated BMI</label>
|
||
<div className="input-group input-group-sm">
|
||
<input
|
||
type="text"
|
||
className="form-control form-control-sm text-center bg-light fw-bold"
|
||
readOnly
|
||
value={
|
||
wellnessForm.height_cm && wellnessForm.weight_kg && parseFloat(wellnessForm.height_cm) > 0
|
||
? (parseFloat(wellnessForm.weight_kg) / Math.pow(parseFloat(wellnessForm.height_cm) / 100, 2)).toFixed(2)
|
||
: '-'
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="col-12 mt-2">
|
||
<label className="form-label small mb-1 fw-bold d-flex justify-content-between">
|
||
<span><i className="bi bi-emoji-frown me-1 text-warning"></i> Pain Severity Score (0 - 10 VRS)</span>
|
||
<span className="text-muted fw-normal">
|
||
{wellnessForm.pain_score === '' ? 'Not assessed' : (
|
||
wellnessForm.pain_score == 0 ? '0 - No Pain' :
|
||
wellnessForm.pain_score <= 3 ? `${wellnessForm.pain_score} - Mild Pain` :
|
||
wellnessForm.pain_score <= 6 ? `${wellnessForm.pain_score} - Moderate Pain` :
|
||
`${wellnessForm.pain_score} - Severe Pain`
|
||
)}
|
||
</span>
|
||
</label>
|
||
<div className="d-flex gap-1 flex-wrap">
|
||
{[0,1,2,3,4,5,6,7,8,9,10].map(val => (
|
||
<button
|
||
key={val}
|
||
type="button"
|
||
className={`btn btn-sm flex-fill py-1 px-1.5 ${wellnessForm.pain_score === String(val) ? (val === 0 ? 'btn-success text-white' : val <= 3 ? 'btn-info text-white' : val <= 6 ? 'btn-warning text-dark' : 'btn-danger text-white') : 'btn-outline-secondary'}`}
|
||
onClick={() => setWellnessForm({...wellnessForm, pain_score: wellnessForm.pain_score === String(val) ? '' : String(val)})}
|
||
>
|
||
{val}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB 2: SUGAR & POC LABS */}
|
||
{wellnessModalTab === 'sugar' && (
|
||
<div className="row g-2.5 mb-2">
|
||
<div className="col-12">
|
||
<div className="alert alert-info py-1.5 px-3 small d-flex align-items-center gap-2 mb-2">
|
||
<i className="bi bi-info-circle-fill fs-6 text-primary"></i>
|
||
<span>Point-of-care capillary blood sugar & POC lab values (NRCES <strong>GeneralAssessment</strong> slice)</span>
|
||
</div>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Random Blood Sugar (RBS)</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="1" className="form-control text-center" placeholder="110" value={wellnessForm.sugar_random} onChange={e => setWellnessForm({...wellnessForm, sugar_random: e.target.value})} />
|
||
<span className="input-group-text">mg/dL</span>
|
||
</div>
|
||
<small className="text-muted" style={{fontSize: '10px'}}>Normal: < 140 mg/dL</small>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Fasting Blood Sugar (FBS)</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="1" className="form-control text-center" placeholder="95" value={wellnessForm.sugar_fasting} onChange={e => setWellnessForm({...wellnessForm, sugar_fasting: e.target.value})} />
|
||
<span className="input-group-text">mg/dL</span>
|
||
</div>
|
||
<small className="text-muted" style={{fontSize: '10px'}}>Normal: 70 - 100 mg/dL</small>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Post-Prandial (PPBS)</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="1" className="form-control text-center" placeholder="130" value={wellnessForm.sugar_pp} onChange={e => setWellnessForm({...wellnessForm, sugar_pp: e.target.value})} />
|
||
<span className="input-group-text">mg/dL</span>
|
||
</div>
|
||
<small className="text-muted" style={{fontSize: '10px'}}>Normal: < 140 mg/dL (2h post meal)</small>
|
||
</div>
|
||
<div className="col-6 col-md-6">
|
||
<label className="form-label small mb-1 fw-bold">HbA1c (Glycated Hemoglobin)</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="0.1" className="form-control text-center" placeholder="5.6" value={wellnessForm.hba1c} onChange={e => setWellnessForm({...wellnessForm, hba1c: e.target.value})} />
|
||
<span className="input-group-text">%</span>
|
||
</div>
|
||
<small className="text-muted" style={{fontSize: '10px'}}>Normal: < 5.7% | Prediabetes: 5.7-6.4% | Diabetes: ≥ 6.5%</small>
|
||
</div>
|
||
<div className="col-6 col-md-6">
|
||
<label className="form-label small mb-1 fw-bold">POC Hemoglobin (Hb)</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="0.1" className="form-control text-center" placeholder="13.5" value={wellnessForm.hemoglobin} onChange={e => setWellnessForm({...wellnessForm, hemoglobin: e.target.value})} />
|
||
<span className="input-group-text">g/dL</span>
|
||
</div>
|
||
<small className="text-muted" style={{fontSize: '10px'}}>Normal: 12.0 - 17.5 g/dL</small>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB 3: WAIST, HIP & ACTIVITY */}
|
||
{wellnessModalTab === 'activity' && (
|
||
<div className="row g-2.5 mb-2">
|
||
<div className="col-12">
|
||
<h6 className="fw-bold small text-primary mb-1 border-bottom pb-1"><i className="bi bi-rulers me-1"></i> Body Circumferences (NRCES BodyMeasurement)</h6>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Waist Circumference</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="0.1" className="form-control text-center" placeholder="85" value={wellnessForm.waist_circumference_cm} onChange={e => setWellnessForm({...wellnessForm, waist_circumference_cm: e.target.value})} />
|
||
<span className="input-group-text">cm</span>
|
||
</div>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Hip Circumference</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="0.1" className="form-control text-center" placeholder="95" value={wellnessForm.hip_circumference_cm} onChange={e => setWellnessForm({...wellnessForm, hip_circumference_cm: e.target.value})} />
|
||
<span className="input-group-text">cm</span>
|
||
</div>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Waist-to-Hip Ratio (WHR)</label>
|
||
<input
|
||
type="text"
|
||
className="form-control form-control-sm text-center bg-light fw-bold"
|
||
readOnly
|
||
value={
|
||
wellnessForm.waist_circumference_cm && wellnessForm.hip_circumference_cm && parseFloat(wellnessForm.hip_circumference_cm) > 0
|
||
? (parseFloat(wellnessForm.waist_circumference_cm) / parseFloat(wellnessForm.hip_circumference_cm)).toFixed(2)
|
||
: '-'
|
||
}
|
||
/>
|
||
<small className="text-muted" style={{fontSize: '10px'}}>Normal: Men < 0.90 | Women < 0.85</small>
|
||
</div>
|
||
|
||
<div className="col-12 mt-2">
|
||
<h6 className="fw-bold small text-success mb-1 border-bottom pb-1"><i className="bi bi-activity me-1"></i> Physical Activity & Sleep (NRCES PhysicalActivity)</h6>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Daily Steps (24h)</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="100" className="form-control text-center" placeholder="8000" value={wellnessForm.daily_steps} onChange={e => setWellnessForm({...wellnessForm, daily_steps: e.target.value})} />
|
||
<span className="input-group-text">steps</span>
|
||
</div>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Sleep Duration</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="0.5" className="form-control text-center" placeholder="7.5" value={wellnessForm.sleep_hours} onChange={e => setWellnessForm({...wellnessForm, sleep_hours: e.target.value})} />
|
||
<span className="input-group-text">hours</span>
|
||
</div>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Exercise Duration</label>
|
||
<div className="input-group input-group-sm">
|
||
<input type="number" step="5" className="form-control text-center" placeholder="30" value={wellnessForm.exercise_min_per_day} onChange={e => setWellnessForm({...wellnessForm, exercise_min_per_day: e.target.value})} />
|
||
<span className="input-group-text">min/day</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB 4: LIFESTYLE & GUIDANCE */}
|
||
{wellnessModalTab === 'lifestyle' && (
|
||
<div className="row g-2.5 mb-2">
|
||
<div className="col-12 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Diet Pattern (ABDM)</label>
|
||
<select className="form-select form-select-sm" value={wellnessForm.diet_type} onChange={e => setWellnessForm({...wellnessForm, diet_type: e.target.value})}>
|
||
<option value="">Select diet...</option>
|
||
<option value="Balanced Diet">Balanced Diet</option>
|
||
<option value="Vegetarian">Vegetarian</option>
|
||
<option value="Lacto-Vegetarian">Lacto-Vegetarian</option>
|
||
<option value="Non-Vegetarian">Non-Vegetarian</option>
|
||
<option value="Vegan">Vegan</option>
|
||
<option value="Diabetic Diet">Diabetic Diet (Low GI)</option>
|
||
<option value="Low Sodium / DASH Diet">Low Sodium / DASH Diet</option>
|
||
<option value="Renal Diet">Renal Diet</option>
|
||
</select>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Tobacco / Smoking</label>
|
||
<select className="form-select form-select-sm" value={wellnessForm.tobacco_status} onChange={e => setWellnessForm({...wellnessForm, tobacco_status: e.target.value})}>
|
||
<option value="">Select status...</option>
|
||
<option value="Never Smoked">Never Smoked</option>
|
||
<option value="Former / Ex-Smoker">Former / Ex-Smoker</option>
|
||
<option value="Current Smoker (Occasional)">Current Smoker (Occasional)</option>
|
||
<option value="Current Smoker (Daily)">Current Smoker (Daily)</option>
|
||
<option value="Tobacco Chewer">Tobacco Chewer</option>
|
||
</select>
|
||
</div>
|
||
<div className="col-6 col-md-4">
|
||
<label className="form-label small mb-1 fw-bold">Alcohol Consumption</label>
|
||
<select className="form-select form-select-sm" value={wellnessForm.alcohol_status} onChange={e => setWellnessForm({...wellnessForm, alcohol_status: e.target.value})}>
|
||
<option value="">Select status...</option>
|
||
<option value="Lifetime Non-Drinker">Lifetime Non-Drinker</option>
|
||
<option value="Former Drinker">Former Drinker</option>
|
||
<option value="Occasional / Social Drinker">Occasional / Social Drinker</option>
|
||
<option value="Regular Drinker">Regular Drinker</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="col-12">
|
||
<label className="form-label small mb-1 fw-bold">Diet & Lifestyle Guidance (ABDM)</label>
|
||
<input type="text" className="form-control form-control-sm" placeholder="e.g. Low sodium diet, 30 min daily brisk walking, adequate hydration" value={wellnessForm.diet_lifestyle_note} onChange={e => setWellnessForm({...wellnessForm, diet_lifestyle_note: e.target.value})} />
|
||
</div>
|
||
|
||
<div className="col-12">
|
||
<label className="form-label small mb-1 fw-bold">General Nursing Observations</label>
|
||
<input type="text" className="form-control form-control-sm" placeholder="e.g. Patient conscious, oriented to time/place, vitals within acceptable limits" value={wellnessForm.general_advice} onChange={e => setWellnessForm({...wellnessForm, general_advice: e.target.value})} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB 5: WOMEN'S HEALTH */}
|
||
{wellnessModalTab === 'women' && (
|
||
<div className="row g-2.5 mb-2">
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small mb-1 fw-bold">Last Menstrual Period (LMP Date)</label>
|
||
<input type="date" className="form-control form-control-sm" value={wellnessForm.women_lmp} onChange={e => setWellnessForm({...wellnessForm, women_lmp: e.target.value})} />
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small mb-1 fw-bold">Pregnancy / Reproductive Status</label>
|
||
<select className="form-select form-select-sm" value={wellnessForm.women_pregnancy_status} onChange={e => setWellnessForm({...wellnessForm, women_pregnancy_status: e.target.value})}>
|
||
<option value="">Select reproductive status...</option>
|
||
<option value="Not Pregnant">Not Pregnant</option>
|
||
<option value="Pregnant - 1st Trimester (1 - 12 wks)">Pregnant - 1st Trimester (1 - 12 wks)</option>
|
||
<option value="Pregnant - 2nd Trimester (13 - 26 wks)">Pregnant - 2nd Trimester (13 - 26 wks)</option>
|
||
<option value="Pregnant - 3rd Trimester (27 - 40+ wks)">Pregnant - 3rd Trimester (27 - 40+ wks)</option>
|
||
<option value="Postpartum (< 6 weeks post delivery)">Postpartum (< 6 weeks post delivery)</option>
|
||
<option value="Lactating / Breastfeeding">Lactating / Breastfeeding</option>
|
||
<option value="Perimenopausal">Perimenopausal</option>
|
||
<option value="Postmenopausal">Postmenopausal</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="modal-footer p-2 bg-light d-flex justify-content-between">
|
||
<button className="btn btn-outline-secondary btn-sm" onClick={() => openWellnessHistory(activeWellnessPatient)}>
|
||
<i className="bi bi-clock-history me-1"></i> Past History
|
||
</button>
|
||
<div className="d-flex gap-2">
|
||
<button className="btn btn-secondary btn-sm" onClick={() => setActiveWellnessPatient(null)}>Cancel</button>
|
||
<button className="btn btn-success btn-sm fw-bold px-3 shadow-sm" onClick={handleSaveWellness}>
|
||
<i className="bi bi-check-circle-fill me-1"></i> Save ABDM Wellness Vitals
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* -------------------------------------------------------------
|
||
MODAL 3: ABDM M2 HEALTH DOCUMENT SCAN & UPLOAD
|
||
------------------------------------------------------------- */}
|
||
{activeDocPatient && (
|
||
<div className="modal show d-block" style={{backgroundColor: 'rgba(0,0,0,0.65)', zIndex: 1060}}>
|
||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||
<div className="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||
<div className="modal-header bg-primary text-white py-2.5">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<i className="bi bi-file-earmark-medical-fill fs-5"></i>
|
||
<div>
|
||
<h6 className="modal-title fw-bold mb-0">Add ABDM M2 Health Document</h6>
|
||
<small className="text-white-50" style={{fontSize: '11px'}}>Scan paper prescriptions, lab reports or clinical documents</small>
|
||
</div>
|
||
</div>
|
||
<button className="btn-close btn-close-white" onClick={() => setActiveDocPatient(null)}></button>
|
||
</div>
|
||
|
||
<div className="modal-body p-3">
|
||
{/* Patient Context Banner */}
|
||
<div className="p-2.5 rounded-3 bg-light border mb-3">
|
||
<div className="d-flex justify-content-between align-items-start flex-wrap gap-1">
|
||
<div>
|
||
<strong className="fs-6 text-dark">{activeDocPatient.fullName || activeDocPatient.p_fname}</strong>
|
||
<div className="small text-muted">
|
||
UHID: <span className="text-danger fw-bold">{activeDocPatient.p_code || activeDocPatient.uhid}</span>
|
||
</div>
|
||
</div>
|
||
{activeDocPatient.abha_id && (
|
||
<span className="badge bg-success">
|
||
<i className="bi bi-shield-check me-1"></i> ABHA: {activeDocPatient.abha_id}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="row g-2 mb-3">
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small fw-bold mb-1">Document Category (ABDM HI Type)</label>
|
||
<select
|
||
className="form-select form-select-sm"
|
||
value={patientDocForm.document_type}
|
||
onChange={e => setPatientDocForm({...patientDocForm, document_type: e.target.value})}
|
||
>
|
||
<option value="Prescription">Prescription Record (PrescriptionRecord)</option>
|
||
<option value="Diagnostic Report / Lab">Diagnostic / Lab Report (DiagnosticReport)</option>
|
||
<option value="Discharge Summary">Discharge Summary (DischargeSummary)</option>
|
||
<option value="Scan / Radiology (X-Ray/CT/MRI)">Radiology / Scan (HealthDocumentRecord)</option>
|
||
<option value="Paper Medical Record">Paper Medical Record (HealthDocumentRecord)</option>
|
||
<option value="Immunization Record">Immunization Record (ImmunizationRecord)</option>
|
||
<option value="Health Document">Other Health Document (HealthDocumentRecord)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label small fw-bold mb-1">Document Date</label>
|
||
<input
|
||
type="date"
|
||
className="form-control form-control-sm"
|
||
value={patientDocForm.document_date}
|
||
onChange={e => setPatientDocForm({...patientDocForm, document_date: e.target.value})}
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-12">
|
||
<label className="form-label small fw-bold mb-1">Remarks / Document Title</label>
|
||
<input
|
||
type="text"
|
||
className="form-control form-control-sm"
|
||
placeholder="e.g. Chest X-Ray Report, Blood CBC Test, Consultation Note"
|
||
value={patientDocForm.remarks}
|
||
onChange={e => setPatientDocForm({...patientDocForm, remarks: e.target.value})}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Camera Capture / Photo Upload */}
|
||
<div className="mb-3 text-center">
|
||
<label className="btn btn-primary btn-lg w-100 py-3 fw-bold rounded-3 shadow-sm">
|
||
<i className="bi bi-camera me-2"></i> Take Document Photo / Choose Image
|
||
<input type="file" accept="image/*" capture="environment" className="d-none" onChange={handleScanImageCapture} />
|
||
</label>
|
||
</div>
|
||
|
||
{scanImagePreview && (
|
||
<div className="position-relative p-2 border rounded-3 bg-light text-center">
|
||
<span className="badge bg-success position-absolute top-0 start-0 m-2">
|
||
<i className="bi bi-check-circle me-1"></i> Document Photo Captured
|
||
</span>
|
||
<img src={scanImagePreview} alt="Document Scan Preview" className="img-fluid rounded-3 border shadow-sm" style={{maxHeight: '200px', objectFit: 'contain'}} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="modal-footer p-2 bg-light">
|
||
<button className="btn btn-secondary btn-sm" onClick={() => setActiveDocPatient(null)} disabled={isSavingDoc}>Cancel</button>
|
||
<button className="btn btn-primary btn-sm fw-bold px-3" onClick={handleSavePatientDocument} disabled={!scanImageBase64 || isSavingDoc}>
|
||
{isSavingDoc ? (
|
||
<span><span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Uploading...</span>
|
||
) : (
|
||
<span><i className="bi bi-cloud-upload-fill me-1"></i> Upload ABDM Health Document</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* -------------------------------------------------------------
|
||
MODAL 4: PATIENT VITALS & WELLNESS HISTORY (COMPREHENSIVE)
|
||
------------------------------------------------------------- */}
|
||
{wellnessHistoryPatient && (
|
||
<div className="modal show d-block" style={{backgroundColor: 'rgba(0,0,0,0.65)', zIndex: 1070}}>
|
||
<div className="modal-dialog modal-dialog-centered modal-xl">
|
||
<div className="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||
<div className="modal-header bg-dark text-white py-2.5">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<i className="bi bi-clock-history fs-5 text-warning"></i>
|
||
<div>
|
||
<h6 className="modal-title fw-bold mb-0">Vitals & Wellness History</h6>
|
||
<small className="text-white-50" style={{fontSize: '11px'}}>
|
||
{wellnessHistoryPatient.fullName || wellnessHistoryPatient.p_fname} (UHID: {wellnessHistoryPatient.p_code || wellnessHistoryPatient.uhid})
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<button className="btn-close btn-close-white" onClick={() => setWellnessHistoryPatient(null)}></button>
|
||
</div>
|
||
|
||
<div className="modal-body p-3">
|
||
{loadingHistory ? (
|
||
<div className="text-center py-4">
|
||
<div className="spinner-border text-primary" role="status"></div>
|
||
<p className="small text-muted mt-2">Loading historical vitals...</p>
|
||
</div>
|
||
) : wellnessHistoryList.length === 0 ? (
|
||
<div className="text-center py-4 bg-light rounded-3">
|
||
<i className="bi bi-heart-pulse fs-2 text-muted opacity-50 d-block mb-1"></i>
|
||
<p className="small text-muted mb-0">No past vitals or wellness entries recorded yet for this patient.</p>
|
||
</div>
|
||
) : (
|
||
<div className="table-responsive">
|
||
<table className="table table-sm table-striped table-hover align-middle mb-0" style={{fontSize: '12px'}}>
|
||
<thead className="table-light">
|
||
<tr>
|
||
<th>Date & Time</th>
|
||
<th>Vitals (BP / Pulse / SpO2 / Temp)</th>
|
||
<th>Body / BMI</th>
|
||
<th>Blood Glucose (mg/dL)</th>
|
||
<th>POC Labs</th>
|
||
<th>Activity / Lifestyle</th>
|
||
<th>Staff</th>
|
||
<th>Advice / Remarks</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{wellnessHistoryList.map(rec => (
|
||
<tr key={rec.id}>
|
||
<td className="fw-semibold text-nowrap">{formatDateTime(rec.recorded_at)}</td>
|
||
<td>
|
||
<div className="d-flex flex-wrap gap-1 align-items-center">
|
||
{rec.bp_systolic && <span className="badge bg-light text-dark border">BP {rec.bp_systolic}/{rec.bp_diastolic}</span>}
|
||
{rec.pulse_rate && <span className="badge bg-light text-dark border">{rec.pulse_rate} bpm</span>}
|
||
{rec.spo2 && <span className="badge bg-info-subtle text-info border border-info-subtle">{rec.spo2}% O2</span>}
|
||
{rec.temperature_f && <span className="badge bg-danger-subtle text-danger border border-danger-subtle">{rec.temperature_f}°F</span>}
|
||
{rec.pain_score !== null && rec.pain_score !== undefined && rec.pain_score !== '' && (
|
||
<span className={`badge ${rec.pain_score == 0 ? 'bg-success-subtle text-success' : rec.pain_score <= 3 ? 'bg-info-subtle text-info' : 'bg-warning-subtle text-warning-emphasis'}`}>
|
||
Pain: {rec.pain_score}/10
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div>
|
||
{rec.bmi && <span className="badge bg-secondary-subtle text-secondary me-1">BMI {rec.bmi}</span>}
|
||
{rec.weight_kg && <small className="text-muted">{rec.weight_kg}kg </small>}
|
||
{rec.height_cm && <small className="text-muted">{rec.height_cm}cm</small>}
|
||
</div>
|
||
{(rec.waist_circumference_cm || rec.waist_hip_ratio) && (
|
||
<small className="text-muted d-block" style={{fontSize: '11px'}}>
|
||
{rec.waist_circumference_cm ? `Waist: ${rec.waist_circumference_cm}cm` : ''}
|
||
{rec.waist_hip_ratio ? ` • WHR: ${rec.waist_hip_ratio}` : ''}
|
||
</small>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<div className="d-flex flex-wrap gap-1">
|
||
{rec.sugar_random && <span className="badge bg-warning-subtle text-warning-emphasis border">RBS: {rec.sugar_random}</span>}
|
||
{rec.sugar_fasting && <span className="badge bg-primary-subtle text-primary border">FBS: {rec.sugar_fasting}</span>}
|
||
{rec.sugar_pp && <span className="badge bg-secondary-subtle text-secondary border">PP: {rec.sugar_pp}</span>}
|
||
{!rec.sugar_random && !rec.sugar_fasting && !rec.sugar_pp && <span className="text-muted">-</span>}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div className="d-flex flex-wrap gap-1">
|
||
{rec.hemoglobin && <span className="badge bg-danger-subtle text-danger border">Hb: {rec.hemoglobin} g/dL</span>}
|
||
{rec.hba1c && <span className="badge bg-success-subtle text-success border">A1c: {rec.hba1c}%</span>}
|
||
{!rec.hemoglobin && !rec.hba1c && <span className="text-muted">-</span>}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div className="small">
|
||
{rec.diet_type && <span className="badge bg-light text-dark border me-1">{rec.diet_type}</span>}
|
||
{rec.daily_steps && <span className="text-muted me-1">{rec.daily_steps} steps</span>}
|
||
{rec.sleep_hours && <span className="text-muted">{rec.sleep_hours}h sleep</span>}
|
||
{!rec.diet_type && !rec.daily_steps && !rec.sleep_hours && <span className="text-muted">-</span>}
|
||
</div>
|
||
</td>
|
||
<td><small className="text-muted">{rec.recorded_by || 'Staff'}</small></td>
|
||
<td><small className="text-truncate d-inline-block" style={{maxWidth: '180px'}} title={rec.general_advice || rec.diet_lifestyle_note}>{rec.general_advice || rec.diet_lifestyle_note || '-'}</small></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="modal-footer p-2 bg-light">
|
||
<button className="btn btn-secondary btn-sm px-3" onClick={() => setWellnessHistoryPatient(null)}>Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||
root.render(<NursingApp />);
|
||
</script>
|
||
</body>
|
||
</html>
|