feat: multi-point patient verification (MRN, Name, DOB) via URL parameters
This commit is contained in:
@@ -112,26 +112,30 @@ def get_latest_readings(
|
|||||||
if device_id:
|
if device_id:
|
||||||
query = query.filter(VitalReading.device_id == device_id)
|
query = query.filter(VitalReading.device_id == device_id)
|
||||||
|
|
||||||
patient_filters = []
|
patient_criteria = []
|
||||||
if mrn:
|
if mrn:
|
||||||
mrn_clean = mrn.strip()
|
mrn_clean = mrn.strip()
|
||||||
patient_filters.append(Patient.mrn == mrn_clean)
|
patient_criteria.append(or_(Patient.mrn == mrn_clean, Patient.patient_id == mrn_clean))
|
||||||
patient_filters.append(Patient.patient_id == mrn_clean)
|
|
||||||
if name:
|
if name:
|
||||||
name_clean = name.strip()
|
name_clean = name.strip()
|
||||||
patient_filters.append(Patient.name.ilike(f"%{name_clean}%"))
|
patient_criteria.append(Patient.name.ilike(f"%{name_clean}%"))
|
||||||
if dob:
|
if dob:
|
||||||
dob_clean = dob.strip()
|
dob_clean = dob.strip().replace("-", "").replace("/", "")
|
||||||
patient_filters.append(Patient.dob.ilike(f"%{dob_clean}%"))
|
patient_criteria.append(or_(
|
||||||
|
Patient.dob.ilike(f"%{dob.strip()}%"),
|
||||||
|
Patient.dob.ilike(f"%{dob_clean}%"),
|
||||||
|
Patient.dob.ilike(f"%{dob.strip()[:4]}%") if len(dob.strip()) >= 4 else False
|
||||||
|
))
|
||||||
|
|
||||||
if patient_filters:
|
if patient_criteria:
|
||||||
matching_patients = db.query(Patient).filter(or_(*patient_filters)).all()
|
from sqlalchemy import and_
|
||||||
|
matching_patients = db.query(Patient).filter(and_(*patient_criteria)).all()
|
||||||
|
if not matching_patients:
|
||||||
|
return []
|
||||||
matching_dev_ids = [p.device_id for p in matching_patients if p.device_id]
|
matching_dev_ids = [p.device_id for p in matching_patients if p.device_id]
|
||||||
matching_mrns = [p.mrn for p in matching_patients if p.mrn] + [p.patient_id for p in matching_patients if p.patient_id]
|
matching_mrns = [p.mrn for p in matching_patients if p.mrn] + [p.patient_id for p in matching_patients if p.patient_id]
|
||||||
|
|
||||||
conds = []
|
conds = []
|
||||||
if mrn:
|
|
||||||
conds.append(VitalReading.patient_id == mrn.strip())
|
|
||||||
if matching_mrns:
|
if matching_mrns:
|
||||||
conds.append(VitalReading.patient_id.in_(matching_mrns))
|
conds.append(VitalReading.patient_id.in_(matching_mrns))
|
||||||
if matching_dev_ids:
|
if matching_dev_ids:
|
||||||
@@ -139,6 +143,8 @@ def get_latest_readings(
|
|||||||
|
|
||||||
if conds:
|
if conds:
|
||||||
query = query.filter(or_(*conds))
|
query = query.filter(or_(*conds))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
||||||
return readings
|
return readings
|
||||||
|
|||||||
@@ -1425,13 +1425,14 @@
|
|||||||
|
|
||||||
<!-- Single Machine Detailed View Wrapper -->
|
<!-- Single Machine Detailed View Wrapper -->
|
||||||
<div id="singleViewWrapper" style="display: none; flex-direction: column; flex: 1;">
|
<div id="singleViewWrapper" style="display: none; flex-direction: column; flex: 1;">
|
||||||
<!-- Targeted Patient MRN Waiting State (shown when filtered patient hasn't connected yet) -->
|
<!-- Targeted Patient Demographics Waiting State (shown when filtered patient hasn't connected yet) -->
|
||||||
<div id="singleTargetWaitingState" style="display: none; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; text-align: center; background: var(--bg-card); border: 1px dashed rgba(6, 182, 212, 0.4); border-radius: 16px; margin: 20px 0;">
|
<div id="singleTargetWaitingState" style="display: none; flex-direction: column; align-items: center; justify-content: center; padding: 50px 20px; text-align: center; background: var(--bg-card); border: 1px dashed rgba(6, 182, 212, 0.4); border-radius: 16px; margin: 20px 0;">
|
||||||
<div style="font-size: 40px; margin-bottom: 12px;">⏳</div>
|
<div style="font-size: 40px; margin-bottom: 12px;">⏳</div>
|
||||||
<div style="font-size: 19px; font-weight: 700; color: var(--text-primary);">Waiting for Contec Monitor Data</div>
|
<div style="font-size: 19px; font-weight: 700; color: var(--text-primary);">Waiting for Contec Monitor Data</div>
|
||||||
<div style="font-size: 15px; color: var(--spo2-color); font-weight: 600; margin-top: 6px;" id="targetWaitingMrnBadge">Patient MRN: ---</div>
|
<div style="font-size: 13px; color: var(--text-secondary); margin-top: 6px;">Verifying incoming monitor telemetry against required teleconsult patient:</div>
|
||||||
<div style="font-size: 13px; color: var(--text-secondary); max-width: 520px; margin-top: 10px; line-height: 1.5;">
|
<div style="margin-top: 12px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap;" id="targetWaitingMrnBadge">Patient Demographics: ---</div>
|
||||||
Filtered to show exclusively this patient's teleconsult telemetry. Once the Contec CMS7000PLUS monitor connects or transmits data, live waveforms and vitals will stream automatically.
|
<div style="font-size: 13px; color: var(--text-secondary); max-width: 540px; margin-top: 14px; line-height: 1.5;">
|
||||||
|
Live waveforms and telemetry stream automatically once the Contec CMS7000PLUS monitor connects and transmits telemetry matching all requested demographics (MRN, Name, DOB).
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 18px; display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(6, 182, 212, 0.1); border: 1px solid rgba(6, 182, 212, 0.25); border-radius: 20px; font-size: 12px; color: var(--spo2-color);">
|
<div style="margin-top: 18px; display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(6, 182, 212, 0.1); border: 1px solid rgba(6, 182, 212, 0.25); border-radius: 20px; font-size: 12px; color: var(--spo2-color);">
|
||||||
<span style="width: 8px; height: 8px; border-radius: 50%; background: var(--spo2-color); display: inline-block;"></span> Listening for live connection...
|
<span style="width: 8px; height: 8px; border-radius: 50%; background: var(--spo2-color); display: inline-block;"></span> Listening for live connection...
|
||||||
@@ -1774,65 +1775,110 @@
|
|||||||
|
|
||||||
function getTargetPatientBadge() {
|
function getTargetPatientBadge() {
|
||||||
const items = [];
|
const items = [];
|
||||||
if (targetMrn) items.push(`MRN: <strong>${targetMrn}</strong>`);
|
if (targetMrn) items.push(`<span class="patient-mrn-badge" style="padding: 4px 10px; font-size: 12px;">📋 MRN: <strong>${targetMrn}</strong></span>`);
|
||||||
if (targetName) items.push(`Name: <strong>${targetName}</strong>`);
|
if (targetName) items.push(`<span class="patient-name-badge" style="padding: 4px 10px; background: rgba(14, 165, 233, 0.15); border: 1px solid rgba(14, 165, 233, 0.3); border-radius: 6px; font-size: 12px; color: #38bdf8;">👤 Name: <strong>${targetName}</strong></span>`);
|
||||||
if (targetDob) items.push(`DOB: <strong>${targetDob}</strong>`);
|
if (targetDob) items.push(`<span class="patient-dob-badge" style="padding: 4px 10px; font-size: 12px;">🎂 DOB: <strong>${targetDob}</strong></span>`);
|
||||||
if (targetGender) items.push(`Gender: <strong>${targetGender}</strong>`);
|
if (targetGender) items.push(`<span class="patient-gender-badge" style="padding: 4px 10px; font-size: 12px;">⚧ Gender: <strong>${targetGender}</strong></span>`);
|
||||||
return items.length > 0 ? items.join(' • ') : 'Target Patient';
|
return items.length > 0 ? items.join('') : '<span>Target Patient</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Extract 4-digit year and digits for robust DOB matching
|
||||||
|
function normalizeDob(str) {
|
||||||
|
if (!str) return { raw: '', digits: '', year: null };
|
||||||
|
const clean = String(str).trim();
|
||||||
|
const yearMatch = clean.match(/\b(19\d\d|20[0-2]\d)\b/);
|
||||||
|
const digits = clean.replace(/\D/g, '');
|
||||||
|
return {
|
||||||
|
raw: clean.toLowerCase(),
|
||||||
|
digits: digits,
|
||||||
|
year: yearMatch ? yearMatch[1] : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Extract individual words for flexible name matching
|
||||||
|
function normalizeNameTokens(str) {
|
||||||
|
if (!str) return [];
|
||||||
|
return String(str)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, '')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(w => w.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check verification for each requested parameter (MRN, Name, DOB, Gender)
|
||||||
|
function checkPatientVerification(dev) {
|
||||||
|
const res = {
|
||||||
|
mrnMatched: !targetMrn,
|
||||||
|
nameMatched: !targetName,
|
||||||
|
dobMatched: !targetDob,
|
||||||
|
genderMatched: !targetGender,
|
||||||
|
allMatched: false
|
||||||
|
};
|
||||||
|
if (!dev) return res;
|
||||||
|
|
||||||
|
const pMrn = String(dev?.mrn || dev?.patient_id || '').toLowerCase().trim();
|
||||||
|
const pName = String(dev?.patient_name || dev?.name || '').toLowerCase().trim();
|
||||||
|
const pDob = String(dev?.patient_dob || dev?.dob || '').toLowerCase().trim();
|
||||||
|
const pGender = String(dev?.patient_gender || dev?.gender || '').toLowerCase().trim();
|
||||||
|
|
||||||
|
// 1. MRN verification
|
||||||
|
if (targetMrn) {
|
||||||
|
const tm = targetMrn.toLowerCase().trim();
|
||||||
|
if (pMrn && (pMrn === tm || pMrn.includes(tm) || tm.includes(pMrn))) {
|
||||||
|
res.mrnMatched = true;
|
||||||
|
} else {
|
||||||
|
const numT = parseInt(tm, 10);
|
||||||
|
const numMrn = parseInt(pMrn, 10);
|
||||||
|
if (!isNaN(numT) && !isNaN(numMrn) && numT === numMrn && tm.length >= 3 && pMrn.length >= 3) {
|
||||||
|
res.mrnMatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Name verification (supports "Shahil", "Shahil Kumar", "Kumar Shahil")
|
||||||
|
if (targetName) {
|
||||||
|
const tn = targetName.toLowerCase().trim();
|
||||||
|
const targetTokens = normalizeNameTokens(targetName);
|
||||||
|
const patientTokens = normalizeNameTokens(pName);
|
||||||
|
if (pName && (pName.includes(tn) || tn.includes(pName))) {
|
||||||
|
res.nameMatched = true;
|
||||||
|
} else if (targetTokens.length > 0 && targetTokens.some(t => patientTokens.includes(t))) {
|
||||||
|
res.nameMatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. DOB verification (supports 1960, 1960-06-22, 22/06/1960, 19600622)
|
||||||
|
if (targetDob) {
|
||||||
|
const td = targetDob.toLowerCase().trim();
|
||||||
|
const tNorm = normalizeDob(targetDob);
|
||||||
|
const pNorm = normalizeDob(pDob);
|
||||||
|
if (pDob && (pDob.includes(td) || td.includes(pDob))) {
|
||||||
|
res.dobMatched = true;
|
||||||
|
} else if (tNorm.year && pNorm.year && tNorm.year === pNorm.year) {
|
||||||
|
res.dobMatched = true;
|
||||||
|
} else if (tNorm.digits && pNorm.digits && (tNorm.digits.includes(pNorm.digits) || pNorm.digits.includes(tNorm.digits))) {
|
||||||
|
res.dobMatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Gender verification
|
||||||
|
if (targetGender) {
|
||||||
|
const tg = targetGender.toLowerCase().trim();
|
||||||
|
if (pGender && (pGender.startsWith(tg[0]) || pGender.includes(tg))) {
|
||||||
|
res.genderMatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.allMatched = res.mrnMatched && res.nameMatched && res.dobMatched && res.genderMatched;
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function: Check if a device or patient matches the target demographics (MRN, Name, DOB, Gender)
|
// Helper function: Check if a device or patient matches the target demographics (MRN, Name, DOB, Gender)
|
||||||
function matchesTargetPatient(dev, devId) {
|
function matchesTargetPatient(dev, devId) {
|
||||||
if (!hasTargetFilter) return true;
|
if (!hasTargetFilter) return true;
|
||||||
if (!dev && !devId) return false;
|
if (!dev && !devId) return false;
|
||||||
|
return checkPatientVerification(dev).allMatched;
|
||||||
const pMrn = String(dev?.mrn || dev?.patient_id || '').toLowerCase().trim();
|
|
||||||
const pName = String(dev?.patient_name || dev?.name || '').toLowerCase().trim();
|
|
||||||
const pDob = String(dev?.patient_dob || dev?.dob || '').toLowerCase().trim();
|
|
||||||
const pGender = String(dev?.patient_gender || dev?.gender || '').toLowerCase().trim();
|
|
||||||
const pBed = String(dev?.bed_id || dev?.bedLabel || '').toLowerCase().trim();
|
|
||||||
const dId = String(devId || dev?.device_id || '').toLowerCase().trim();
|
|
||||||
|
|
||||||
// 1. If targetName is explicitly specified: must match patient name
|
|
||||||
if (targetName) {
|
|
||||||
const tn = targetName.toLowerCase().trim();
|
|
||||||
const nameMatch = pName && (pName.includes(tn) || tn.includes(pName));
|
|
||||||
if (!nameMatch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If targetDob is explicitly specified: must match patient DOB / birth year
|
|
||||||
if (targetDob) {
|
|
||||||
const td = targetDob.toLowerCase().trim();
|
|
||||||
const dobMatch = pDob && (pDob.includes(td) || td.includes(pDob));
|
|
||||||
if (!dobMatch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. If targetGender is explicitly specified: must match gender
|
|
||||||
if (targetGender) {
|
|
||||||
const tg = targetGender.toLowerCase().trim();
|
|
||||||
const genderMatch = pGender && (pGender.startsWith(tg[0]) || pGender.includes(tg));
|
|
||||||
if (!genderMatch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. If targetMrn is specified: strictly match patient MRN only
|
|
||||||
if (targetMrn) {
|
|
||||||
const tm = targetMrn.toLowerCase().trim();
|
|
||||||
let mrnMatch = false;
|
|
||||||
|
|
||||||
if (pMrn) {
|
|
||||||
if (pMrn === tm) mrnMatch = true;
|
|
||||||
// Also support numeric equivalence (e.g. "09091102" vs "9091102") with minimum length 3
|
|
||||||
const numT = parseInt(tm, 10);
|
|
||||||
const numMrn = parseInt(pMrn, 10);
|
|
||||||
if (!isNaN(numT) && !isNaN(numMrn) && numT === numMrn && tm.length >= 3 && pMrn.length >= 3) {
|
|
||||||
mrnMatch = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mrnMatch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward-compatible alias
|
// Backward-compatible alias
|
||||||
@@ -2121,8 +2167,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Load latest readings to backfill vitals for each device
|
// 2. Load latest readings to backfill vitals for each device
|
||||||
const backfillUrl = hasTargetFilter && targetMrn
|
const backfillParams = new URLSearchParams();
|
||||||
? `${API_BASE}/latest-readings?limit=50&mrn=${encodeURIComponent(targetMrn)}`
|
backfillParams.set('limit', '50');
|
||||||
|
if (targetMrn) backfillParams.set('mrn', targetMrn);
|
||||||
|
if (targetName) backfillParams.set('name', targetName);
|
||||||
|
if (targetDob) backfillParams.set('dob', targetDob);
|
||||||
|
const backfillUrl = hasTargetFilter
|
||||||
|
? `${API_BASE}/latest-readings?${backfillParams.toString()}`
|
||||||
: `${API_BASE}/latest-readings?limit=100`;
|
: `${API_BASE}/latest-readings?limit=100`;
|
||||||
const readResp = await fetch(backfillUrl);
|
const readResp = await fetch(backfillUrl);
|
||||||
if (readResp.ok) {
|
if (readResp.ok) {
|
||||||
@@ -2560,7 +2611,7 @@
|
|||||||
if (waitingState) {
|
if (waitingState) {
|
||||||
waitingState.style.display = 'flex';
|
waitingState.style.display = 'flex';
|
||||||
const badge = document.getElementById('targetWaitingMrnBadge');
|
const badge = document.getElementById('targetWaitingMrnBadge');
|
||||||
if (badge) badge.innerHTML = `Target Patient: ${getTargetPatientBadge()}`;
|
if (badge) badge.innerHTML = getTargetPatientBadge();
|
||||||
}
|
}
|
||||||
if (patientBanner) patientBanner.style.display = 'none';
|
if (patientBanner) patientBanner.style.display = 'none';
|
||||||
if (vitalsGrid) vitalsGrid.style.display = 'none';
|
if (vitalsGrid) vitalsGrid.style.display = 'none';
|
||||||
@@ -2584,10 +2635,11 @@
|
|||||||
|
|
||||||
const bannerEl = document.getElementById('singlePatientBanner');
|
const bannerEl = document.getElementById('singlePatientBanner');
|
||||||
if (bannerEl) {
|
if (bannerEl) {
|
||||||
document.getElementById('bannerPatientName').textContent = pName;
|
const verifiedTag = '<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 6px;background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#10b981;font-size:10px;font-weight:700;margin-left:6px;">✓ Verified</span>';
|
||||||
document.getElementById('bannerMrn').textContent = `MRN: ${pMrn}`;
|
document.getElementById('bannerPatientName').innerHTML = `${pName} ${targetName ? verifiedTag : ''}`;
|
||||||
document.getElementById('bannerGender').textContent = `Gender: ${pGender}`;
|
document.getElementById('bannerMrn').innerHTML = `MRN: ${pMrn} ${targetMrn ? verifiedTag : ''}`;
|
||||||
document.getElementById('bannerDob').textContent = `DOB: ${pDob}`;
|
document.getElementById('bannerGender').innerHTML = `Gender: ${pGender} ${targetGender ? verifiedTag : ''}`;
|
||||||
|
document.getElementById('bannerDob').innerHTML = `DOB: ${pDob} ${targetDob ? verifiedTag : ''}`;
|
||||||
document.getElementById('bannerBed').textContent = `Location: ${pBed}`;
|
document.getElementById('bannerBed').textContent = `Location: ${pBed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-10
@@ -112,26 +112,30 @@ def get_latest_readings(
|
|||||||
if device_id:
|
if device_id:
|
||||||
query = query.filter(VitalReading.device_id == device_id)
|
query = query.filter(VitalReading.device_id == device_id)
|
||||||
|
|
||||||
patient_filters = []
|
patient_criteria = []
|
||||||
if mrn:
|
if mrn:
|
||||||
mrn_clean = mrn.strip()
|
mrn_clean = mrn.strip()
|
||||||
patient_filters.append(Patient.mrn == mrn_clean)
|
patient_criteria.append(or_(Patient.mrn == mrn_clean, Patient.patient_id == mrn_clean))
|
||||||
patient_filters.append(Patient.patient_id == mrn_clean)
|
|
||||||
if name:
|
if name:
|
||||||
name_clean = name.strip()
|
name_clean = name.strip()
|
||||||
patient_filters.append(Patient.name.ilike(f"%{name_clean}%"))
|
patient_criteria.append(Patient.name.ilike(f"%{name_clean}%"))
|
||||||
if dob:
|
if dob:
|
||||||
dob_clean = dob.strip()
|
dob_clean = dob.strip().replace("-", "").replace("/", "")
|
||||||
patient_filters.append(Patient.dob.ilike(f"%{dob_clean}%"))
|
patient_criteria.append(or_(
|
||||||
|
Patient.dob.ilike(f"%{dob.strip()}%"),
|
||||||
|
Patient.dob.ilike(f"%{dob_clean}%"),
|
||||||
|
Patient.dob.ilike(f"%{dob.strip()[:4]}%") if len(dob.strip()) >= 4 else False
|
||||||
|
))
|
||||||
|
|
||||||
if patient_filters:
|
if patient_criteria:
|
||||||
matching_patients = db.query(Patient).filter(or_(*patient_filters)).all()
|
from sqlalchemy import and_
|
||||||
|
matching_patients = db.query(Patient).filter(and_(*patient_criteria)).all()
|
||||||
|
if not matching_patients:
|
||||||
|
return []
|
||||||
matching_dev_ids = [p.device_id for p in matching_patients if p.device_id]
|
matching_dev_ids = [p.device_id for p in matching_patients if p.device_id]
|
||||||
matching_mrns = [p.mrn for p in matching_patients if p.mrn] + [p.patient_id for p in matching_patients if p.patient_id]
|
matching_mrns = [p.mrn for p in matching_patients if p.mrn] + [p.patient_id for p in matching_patients if p.patient_id]
|
||||||
|
|
||||||
conds = []
|
conds = []
|
||||||
if mrn:
|
|
||||||
conds.append(VitalReading.patient_id == mrn.strip())
|
|
||||||
if matching_mrns:
|
if matching_mrns:
|
||||||
conds.append(VitalReading.patient_id.in_(matching_mrns))
|
conds.append(VitalReading.patient_id.in_(matching_mrns))
|
||||||
if matching_dev_ids:
|
if matching_dev_ids:
|
||||||
@@ -139,6 +143,8 @@ def get_latest_readings(
|
|||||||
|
|
||||||
if conds:
|
if conds:
|
||||||
query = query.filter(or_(*conds))
|
query = query.filter(or_(*conds))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
||||||
return readings
|
return readings
|
||||||
|
|||||||
+117
-65
@@ -1425,13 +1425,14 @@
|
|||||||
|
|
||||||
<!-- Single Machine Detailed View Wrapper -->
|
<!-- Single Machine Detailed View Wrapper -->
|
||||||
<div id="singleViewWrapper" style="display: none; flex-direction: column; flex: 1;">
|
<div id="singleViewWrapper" style="display: none; flex-direction: column; flex: 1;">
|
||||||
<!-- Targeted Patient MRN Waiting State (shown when filtered patient hasn't connected yet) -->
|
<!-- Targeted Patient Demographics Waiting State (shown when filtered patient hasn't connected yet) -->
|
||||||
<div id="singleTargetWaitingState" style="display: none; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; text-align: center; background: var(--bg-card); border: 1px dashed rgba(6, 182, 212, 0.4); border-radius: 16px; margin: 20px 0;">
|
<div id="singleTargetWaitingState" style="display: none; flex-direction: column; align-items: center; justify-content: center; padding: 50px 20px; text-align: center; background: var(--bg-card); border: 1px dashed rgba(6, 182, 212, 0.4); border-radius: 16px; margin: 20px 0;">
|
||||||
<div style="font-size: 40px; margin-bottom: 12px;">⏳</div>
|
<div style="font-size: 40px; margin-bottom: 12px;">⏳</div>
|
||||||
<div style="font-size: 19px; font-weight: 700; color: var(--text-primary);">Waiting for Contec Monitor Data</div>
|
<div style="font-size: 19px; font-weight: 700; color: var(--text-primary);">Waiting for Contec Monitor Data</div>
|
||||||
<div style="font-size: 15px; color: var(--spo2-color); font-weight: 600; margin-top: 6px;" id="targetWaitingMrnBadge">Patient MRN: ---</div>
|
<div style="font-size: 13px; color: var(--text-secondary); margin-top: 6px;">Verifying incoming monitor telemetry against required teleconsult patient:</div>
|
||||||
<div style="font-size: 13px; color: var(--text-secondary); max-width: 520px; margin-top: 10px; line-height: 1.5;">
|
<div style="margin-top: 12px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap;" id="targetWaitingMrnBadge">Patient Demographics: ---</div>
|
||||||
Filtered to show exclusively this patient's teleconsult telemetry. Once the Contec CMS7000PLUS monitor connects or transmits data, live waveforms and vitals will stream automatically.
|
<div style="font-size: 13px; color: var(--text-secondary); max-width: 540px; margin-top: 14px; line-height: 1.5;">
|
||||||
|
Live waveforms and telemetry stream automatically once the Contec CMS7000PLUS monitor connects and transmits telemetry matching all requested demographics (MRN, Name, DOB).
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 18px; display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(6, 182, 212, 0.1); border: 1px solid rgba(6, 182, 212, 0.25); border-radius: 20px; font-size: 12px; color: var(--spo2-color);">
|
<div style="margin-top: 18px; display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(6, 182, 212, 0.1); border: 1px solid rgba(6, 182, 212, 0.25); border-radius: 20px; font-size: 12px; color: var(--spo2-color);">
|
||||||
<span style="width: 8px; height: 8px; border-radius: 50%; background: var(--spo2-color); display: inline-block;"></span> Listening for live connection...
|
<span style="width: 8px; height: 8px; border-radius: 50%; background: var(--spo2-color); display: inline-block;"></span> Listening for live connection...
|
||||||
@@ -1774,65 +1775,110 @@
|
|||||||
|
|
||||||
function getTargetPatientBadge() {
|
function getTargetPatientBadge() {
|
||||||
const items = [];
|
const items = [];
|
||||||
if (targetMrn) items.push(`MRN: <strong>${targetMrn}</strong>`);
|
if (targetMrn) items.push(`<span class="patient-mrn-badge" style="padding: 4px 10px; font-size: 12px;">📋 MRN: <strong>${targetMrn}</strong></span>`);
|
||||||
if (targetName) items.push(`Name: <strong>${targetName}</strong>`);
|
if (targetName) items.push(`<span class="patient-name-badge" style="padding: 4px 10px; background: rgba(14, 165, 233, 0.15); border: 1px solid rgba(14, 165, 233, 0.3); border-radius: 6px; font-size: 12px; color: #38bdf8;">👤 Name: <strong>${targetName}</strong></span>`);
|
||||||
if (targetDob) items.push(`DOB: <strong>${targetDob}</strong>`);
|
if (targetDob) items.push(`<span class="patient-dob-badge" style="padding: 4px 10px; font-size: 12px;">🎂 DOB: <strong>${targetDob}</strong></span>`);
|
||||||
if (targetGender) items.push(`Gender: <strong>${targetGender}</strong>`);
|
if (targetGender) items.push(`<span class="patient-gender-badge" style="padding: 4px 10px; font-size: 12px;">⚧ Gender: <strong>${targetGender}</strong></span>`);
|
||||||
return items.length > 0 ? items.join(' • ') : 'Target Patient';
|
return items.length > 0 ? items.join('') : '<span>Target Patient</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Extract 4-digit year and digits for robust DOB matching
|
||||||
|
function normalizeDob(str) {
|
||||||
|
if (!str) return { raw: '', digits: '', year: null };
|
||||||
|
const clean = String(str).trim();
|
||||||
|
const yearMatch = clean.match(/\b(19\d\d|20[0-2]\d)\b/);
|
||||||
|
const digits = clean.replace(/\D/g, '');
|
||||||
|
return {
|
||||||
|
raw: clean.toLowerCase(),
|
||||||
|
digits: digits,
|
||||||
|
year: yearMatch ? yearMatch[1] : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Extract individual words for flexible name matching
|
||||||
|
function normalizeNameTokens(str) {
|
||||||
|
if (!str) return [];
|
||||||
|
return String(str)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, '')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(w => w.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check verification for each requested parameter (MRN, Name, DOB, Gender)
|
||||||
|
function checkPatientVerification(dev) {
|
||||||
|
const res = {
|
||||||
|
mrnMatched: !targetMrn,
|
||||||
|
nameMatched: !targetName,
|
||||||
|
dobMatched: !targetDob,
|
||||||
|
genderMatched: !targetGender,
|
||||||
|
allMatched: false
|
||||||
|
};
|
||||||
|
if (!dev) return res;
|
||||||
|
|
||||||
|
const pMrn = String(dev?.mrn || dev?.patient_id || '').toLowerCase().trim();
|
||||||
|
const pName = String(dev?.patient_name || dev?.name || '').toLowerCase().trim();
|
||||||
|
const pDob = String(dev?.patient_dob || dev?.dob || '').toLowerCase().trim();
|
||||||
|
const pGender = String(dev?.patient_gender || dev?.gender || '').toLowerCase().trim();
|
||||||
|
|
||||||
|
// 1. MRN verification
|
||||||
|
if (targetMrn) {
|
||||||
|
const tm = targetMrn.toLowerCase().trim();
|
||||||
|
if (pMrn && (pMrn === tm || pMrn.includes(tm) || tm.includes(pMrn))) {
|
||||||
|
res.mrnMatched = true;
|
||||||
|
} else {
|
||||||
|
const numT = parseInt(tm, 10);
|
||||||
|
const numMrn = parseInt(pMrn, 10);
|
||||||
|
if (!isNaN(numT) && !isNaN(numMrn) && numT === numMrn && tm.length >= 3 && pMrn.length >= 3) {
|
||||||
|
res.mrnMatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Name verification (supports "Shahil", "Shahil Kumar", "Kumar Shahil")
|
||||||
|
if (targetName) {
|
||||||
|
const tn = targetName.toLowerCase().trim();
|
||||||
|
const targetTokens = normalizeNameTokens(targetName);
|
||||||
|
const patientTokens = normalizeNameTokens(pName);
|
||||||
|
if (pName && (pName.includes(tn) || tn.includes(pName))) {
|
||||||
|
res.nameMatched = true;
|
||||||
|
} else if (targetTokens.length > 0 && targetTokens.some(t => patientTokens.includes(t))) {
|
||||||
|
res.nameMatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. DOB verification (supports 1960, 1960-06-22, 22/06/1960, 19600622)
|
||||||
|
if (targetDob) {
|
||||||
|
const td = targetDob.toLowerCase().trim();
|
||||||
|
const tNorm = normalizeDob(targetDob);
|
||||||
|
const pNorm = normalizeDob(pDob);
|
||||||
|
if (pDob && (pDob.includes(td) || td.includes(pDob))) {
|
||||||
|
res.dobMatched = true;
|
||||||
|
} else if (tNorm.year && pNorm.year && tNorm.year === pNorm.year) {
|
||||||
|
res.dobMatched = true;
|
||||||
|
} else if (tNorm.digits && pNorm.digits && (tNorm.digits.includes(pNorm.digits) || pNorm.digits.includes(tNorm.digits))) {
|
||||||
|
res.dobMatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Gender verification
|
||||||
|
if (targetGender) {
|
||||||
|
const tg = targetGender.toLowerCase().trim();
|
||||||
|
if (pGender && (pGender.startsWith(tg[0]) || pGender.includes(tg))) {
|
||||||
|
res.genderMatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.allMatched = res.mrnMatched && res.nameMatched && res.dobMatched && res.genderMatched;
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function: Check if a device or patient matches the target demographics (MRN, Name, DOB, Gender)
|
// Helper function: Check if a device or patient matches the target demographics (MRN, Name, DOB, Gender)
|
||||||
function matchesTargetPatient(dev, devId) {
|
function matchesTargetPatient(dev, devId) {
|
||||||
if (!hasTargetFilter) return true;
|
if (!hasTargetFilter) return true;
|
||||||
if (!dev && !devId) return false;
|
if (!dev && !devId) return false;
|
||||||
|
return checkPatientVerification(dev).allMatched;
|
||||||
const pMrn = String(dev?.mrn || dev?.patient_id || '').toLowerCase().trim();
|
|
||||||
const pName = String(dev?.patient_name || dev?.name || '').toLowerCase().trim();
|
|
||||||
const pDob = String(dev?.patient_dob || dev?.dob || '').toLowerCase().trim();
|
|
||||||
const pGender = String(dev?.patient_gender || dev?.gender || '').toLowerCase().trim();
|
|
||||||
const pBed = String(dev?.bed_id || dev?.bedLabel || '').toLowerCase().trim();
|
|
||||||
const dId = String(devId || dev?.device_id || '').toLowerCase().trim();
|
|
||||||
|
|
||||||
// 1. If targetName is explicitly specified: must match patient name
|
|
||||||
if (targetName) {
|
|
||||||
const tn = targetName.toLowerCase().trim();
|
|
||||||
const nameMatch = pName && (pName.includes(tn) || tn.includes(pName));
|
|
||||||
if (!nameMatch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If targetDob is explicitly specified: must match patient DOB / birth year
|
|
||||||
if (targetDob) {
|
|
||||||
const td = targetDob.toLowerCase().trim();
|
|
||||||
const dobMatch = pDob && (pDob.includes(td) || td.includes(pDob));
|
|
||||||
if (!dobMatch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. If targetGender is explicitly specified: must match gender
|
|
||||||
if (targetGender) {
|
|
||||||
const tg = targetGender.toLowerCase().trim();
|
|
||||||
const genderMatch = pGender && (pGender.startsWith(tg[0]) || pGender.includes(tg));
|
|
||||||
if (!genderMatch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. If targetMrn is specified: strictly match patient MRN only
|
|
||||||
if (targetMrn) {
|
|
||||||
const tm = targetMrn.toLowerCase().trim();
|
|
||||||
let mrnMatch = false;
|
|
||||||
|
|
||||||
if (pMrn) {
|
|
||||||
if (pMrn === tm) mrnMatch = true;
|
|
||||||
// Also support numeric equivalence (e.g. "09091102" vs "9091102") with minimum length 3
|
|
||||||
const numT = parseInt(tm, 10);
|
|
||||||
const numMrn = parseInt(pMrn, 10);
|
|
||||||
if (!isNaN(numT) && !isNaN(numMrn) && numT === numMrn && tm.length >= 3 && pMrn.length >= 3) {
|
|
||||||
mrnMatch = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mrnMatch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward-compatible alias
|
// Backward-compatible alias
|
||||||
@@ -2121,8 +2167,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Load latest readings to backfill vitals for each device
|
// 2. Load latest readings to backfill vitals for each device
|
||||||
const backfillUrl = hasTargetFilter && targetMrn
|
const backfillParams = new URLSearchParams();
|
||||||
? `${API_BASE}/latest-readings?limit=50&mrn=${encodeURIComponent(targetMrn)}`
|
backfillParams.set('limit', '50');
|
||||||
|
if (targetMrn) backfillParams.set('mrn', targetMrn);
|
||||||
|
if (targetName) backfillParams.set('name', targetName);
|
||||||
|
if (targetDob) backfillParams.set('dob', targetDob);
|
||||||
|
const backfillUrl = hasTargetFilter
|
||||||
|
? `${API_BASE}/latest-readings?${backfillParams.toString()}`
|
||||||
: `${API_BASE}/latest-readings?limit=100`;
|
: `${API_BASE}/latest-readings?limit=100`;
|
||||||
const readResp = await fetch(backfillUrl);
|
const readResp = await fetch(backfillUrl);
|
||||||
if (readResp.ok) {
|
if (readResp.ok) {
|
||||||
@@ -2560,7 +2611,7 @@
|
|||||||
if (waitingState) {
|
if (waitingState) {
|
||||||
waitingState.style.display = 'flex';
|
waitingState.style.display = 'flex';
|
||||||
const badge = document.getElementById('targetWaitingMrnBadge');
|
const badge = document.getElementById('targetWaitingMrnBadge');
|
||||||
if (badge) badge.innerHTML = `Target Patient: ${getTargetPatientBadge()}`;
|
if (badge) badge.innerHTML = getTargetPatientBadge();
|
||||||
}
|
}
|
||||||
if (patientBanner) patientBanner.style.display = 'none';
|
if (patientBanner) patientBanner.style.display = 'none';
|
||||||
if (vitalsGrid) vitalsGrid.style.display = 'none';
|
if (vitalsGrid) vitalsGrid.style.display = 'none';
|
||||||
@@ -2584,10 +2635,11 @@
|
|||||||
|
|
||||||
const bannerEl = document.getElementById('singlePatientBanner');
|
const bannerEl = document.getElementById('singlePatientBanner');
|
||||||
if (bannerEl) {
|
if (bannerEl) {
|
||||||
document.getElementById('bannerPatientName').textContent = pName;
|
const verifiedTag = '<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 6px;background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#10b981;font-size:10px;font-weight:700;margin-left:6px;">✓ Verified</span>';
|
||||||
document.getElementById('bannerMrn').textContent = `MRN: ${pMrn}`;
|
document.getElementById('bannerPatientName').innerHTML = `${pName} ${targetName ? verifiedTag : ''}`;
|
||||||
document.getElementById('bannerGender').textContent = `Gender: ${pGender}`;
|
document.getElementById('bannerMrn').innerHTML = `MRN: ${pMrn} ${targetMrn ? verifiedTag : ''}`;
|
||||||
document.getElementById('bannerDob').textContent = `DOB: ${pDob}`;
|
document.getElementById('bannerGender').innerHTML = `Gender: ${pGender} ${targetGender ? verifiedTag : ''}`;
|
||||||
|
document.getElementById('bannerDob').innerHTML = `DOB: ${pDob} ${targetDob ? verifiedTag : ''}`;
|
||||||
document.getElementById('bannerBed').textContent = `Location: ${pBed}`;
|
document.getElementById('bannerBed').textContent = `Location: ${pBed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user