435 lines
25 KiB
JavaScript
435 lines
25 KiB
JavaScript
// ============================================================
|
|
// PHYSICAL EXAM GUIDE — step-by-step OSCE reference + report generator
|
|
// ============================================================
|
|
// Sources for the exam content:
|
|
// - Bates' Guide to Physical Examination, 13th edition (technique)
|
|
// - Nelson Textbook of Pediatrics, 22nd edition (age-specific content)
|
|
// - Hutchison's Clinical Methods, 25th edition (systematic approach)
|
|
// - Developmental reflex timing: Fenichel Clinical Pediatric Neurology, 8th ed
|
|
//
|
|
// Data model:
|
|
// PE_DATA[ageGroup][system] = { overview, components: [{ name, steps: [{label, method, normal}], abnormalHints }] }
|
|
// Each step has its own Normal / Abnormal / Skip toggle so the physician
|
|
// ticks the exam off step-by-step and flags specific abnormal findings.
|
|
// Report generation (POST /api/generate-pe-narrative) receives the flat
|
|
// step array and produces a Technique + Findings narrative.
|
|
// ============================================================
|
|
|
|
var SCALES = {};
|
|
var SYSTEM_SCALES = {};
|
|
var APTM_LEGEND = [];
|
|
var INNOCENT_MURMURS = [];
|
|
var RESP_SOUNDS = [];
|
|
var CARDIAC_SOUNDS = [];
|
|
var PE_DATA = {};
|
|
var PE_GUIDE_DATA_URL = '/data/pe-guide.json';
|
|
var peGuideDataPromise = null;
|
|
|
|
function applyPeGuideData(data) {
|
|
data = data || {};
|
|
SCALES = data.scales || {};
|
|
SYSTEM_SCALES = data.systemScales || {};
|
|
APTM_LEGEND = data.aptmLegend || [];
|
|
INNOCENT_MURMURS = data.innocentMurmurs || [];
|
|
RESP_SOUNDS = data.respiratorySounds || [];
|
|
CARDIAC_SOUNDS = data.cardiacSounds || [];
|
|
PE_DATA = data.peData || {};
|
|
}
|
|
|
|
function loadPeGuideData() {
|
|
if (!peGuideDataPromise) {
|
|
peGuideDataPromise = fetch(PE_GUIDE_DATA_URL)
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('Failed to load PE Guide data');
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
applyPeGuideData(data);
|
|
return data;
|
|
});
|
|
}
|
|
return peGuideDataPromise;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────
|
|
// STATE + RENDER
|
|
// ────────────────────────────────────────────────────────────
|
|
var _inited = false;
|
|
// state keyed as 'system-componentIdx-stepIdx' → { label, method, normal, status, note }
|
|
var state = {};
|
|
var currentSystem = 'msk';
|
|
var currentAgeGroup = '';
|
|
|
|
document.addEventListener('tabChanged', function (e) {
|
|
if (e.detail.tab !== 'peguide' || _inited) return;
|
|
_inited = true;
|
|
init().catch(function (err) {
|
|
console.error('[PEGuide] init error', err);
|
|
var content = document.getElementById('pe-content');
|
|
if (content) content.innerHTML = '<p style="text-align:center;color:#ef4444;padding:40px;">Unable to load PE Guide data.</p>';
|
|
});
|
|
});
|
|
|
|
async function init() {
|
|
await loadPeGuideData();
|
|
var ageSelect = document.getElementById('pe-age-group');
|
|
var content = document.getElementById('pe-content');
|
|
var actions = document.getElementById('pe-actions');
|
|
|
|
function applyAgeGroup(ag) {
|
|
currentAgeGroup = ag;
|
|
state = {};
|
|
document.getElementById('pe-output').classList.add('hidden');
|
|
if (!currentAgeGroup || !PE_DATA[currentAgeGroup]) {
|
|
content.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">Select an age group above.</p>';
|
|
actions.style.display = 'none';
|
|
return;
|
|
}
|
|
renderSystem();
|
|
actions.style.display = 'flex';
|
|
}
|
|
ageSelect.addEventListener('change', function () {
|
|
applyAgeGroup(ageSelect.value);
|
|
if (window.UIState) window.UIState.set('pe.ageGroup', ageSelect.value || '');
|
|
});
|
|
|
|
function applySystem(sys) {
|
|
document.querySelectorAll('[data-pesystem]').forEach(function (b) {
|
|
b.classList.toggle('active', b.dataset.pesystem === sys);
|
|
});
|
|
currentSystem = sys;
|
|
state = {};
|
|
document.getElementById('pe-output').classList.add('hidden');
|
|
if (currentAgeGroup) renderSystem();
|
|
}
|
|
document.querySelectorAll('[data-pesystem]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
applySystem(btn.dataset.pesystem);
|
|
if (window.UIState) window.UIState.set('pe.system', btn.dataset.pesystem);
|
|
});
|
|
});
|
|
|
|
// Restore persisted selections. Age group must be restored first because
|
|
// the system render depends on it.
|
|
if (window.UIState) {
|
|
var savedAge = window.UIState.get('pe.ageGroup');
|
|
if (savedAge && PE_DATA[savedAge]) {
|
|
ageSelect.value = savedAge;
|
|
applyAgeGroup(savedAge);
|
|
}
|
|
var savedSys = window.UIState.get('pe.system');
|
|
if (savedSys && /^(msk|neuro|resp|cv)$/.test(savedSys)) {
|
|
applySystem(savedSys);
|
|
}
|
|
}
|
|
|
|
document.getElementById('pe-all-normal').addEventListener('click', setAllNormal);
|
|
document.getElementById('pe-all-clear').addEventListener('click', clearAll);
|
|
document.getElementById('pe-generate-btn').addEventListener('click', generate);
|
|
}
|
|
|
|
// Responsive two-column grid helper: side-by-side on wide screens, stacked on narrow
|
|
var TWO_COL_GRID = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;align-items:start;';
|
|
|
|
// Render a single sound card with native <audio controls> (play/pause/seek
|
|
// free on all platforms incl. mobile). Falls back to a synth play button
|
|
// when the sound has no real recording.
|
|
function renderSoundCard(s) {
|
|
var player = '<audio class="pe-audio" controls preload="none" src="' + esc(s.src) + '" style="width:100%;height:36px;"></audio>';
|
|
var html = '';
|
|
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px;background:#fff;">';
|
|
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);margin-bottom:4px;">' + esc(s.title) + '</div>';
|
|
if (s.where) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Where:</strong> ' + esc(s.where) + '</div>';
|
|
if (s.rate) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Rate:</strong> ' + esc(s.rate) + '</div>';
|
|
if (s.features) html += ' <div style="font-size:12px;color:var(--g700);line-height:1.5;margin-bottom:3px;"><strong>Features:</strong> ' + esc(s.features) + '</div>';
|
|
if (s.clinical) html += ' <div style="font-size:11px;color:var(--g600);line-height:1.5;margin-bottom:8px;"><strong>Clinical:</strong> ' + esc(s.clinical) + '</div>';
|
|
html += ' <div style="margin-top:6px;">' + player + '</div>';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function renderSoundsLibrary(title, sounds, accent, accentTint, icon) {
|
|
var html = '';
|
|
html += '<div class="card" style="margin-bottom:14px;border:1px solid ' + accent + '33;">';
|
|
html += ' <div class="card-header" style="background:' + accentTint + ';"><h3 style="margin:0;font-size:14px;color:' + accent + ';"><i class="fas ' + icon + '"></i> ' + esc(title) + '</h3></div>';
|
|
html += ' <div style="padding:10px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
|
|
sounds.forEach(function (s) { html += renderSoundCard(s); });
|
|
html += ' </div>';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function renderSystem() {
|
|
var content = document.getElementById('pe-content');
|
|
var group = PE_DATA[currentAgeGroup];
|
|
if (!group || !group[currentSystem]) {
|
|
content.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">No data for this combination.</p>';
|
|
return;
|
|
}
|
|
var section = group[currentSystem];
|
|
var accentMap = { msk: '#0891b2', neuro: '#7c3aed', resp: '#0ea5e9', cv: '#e11d48' }; // cyan / purple / sky / rose
|
|
var accentTintMap = { msk: '#ecfeff', neuro: '#f5f3ff', resp: '#f0f9ff', cv: '#fff1f2' };
|
|
var iconMap = { msk: 'bone', neuro: 'brain', resp: 'lungs', cv: 'heart-pulse' };
|
|
var labelMap = { msk: 'Musculoskeletal', neuro: 'Neurologic', resp: 'Respiratory', cv: 'Cardiovascular' };
|
|
var accent = accentMap[currentSystem] || '#0891b2';
|
|
var accentTint = accentTintMap[currentSystem] || '#ecfeff';
|
|
var icon = iconMap[currentSystem] || 'circle-info';
|
|
var sysLabel = labelMap[currentSystem] || currentSystem;
|
|
var html = '';
|
|
|
|
// ─ Overview banner ─
|
|
html += '<div class="card" style="margin-bottom:14px;border-left:4px solid ' + accent + ';">';
|
|
html += ' <div class="card-header" style="border:0;padding:14px 16px 6px;"><h3 style="margin:0;font-size:16px;color:' + accent + ';"><i class="fas fa-' + icon + '"></i> ' + esc(group.label) + ' — ' + sysLabel + '</h3></div>';
|
|
html += ' <div style="padding:0 16px 14px;font-size:13px;line-height:1.65;color:var(--g700);">' + esc(section.overview) + '</div>';
|
|
html += '</div>';
|
|
|
|
// ─ APTM cardiac auscultation diagram + innocent-murmur map (cv only) ─
|
|
if (currentSystem === 'cv') {
|
|
html += '<div class="card" style="margin-bottom:14px;border:1px solid ' + accent + '33;">';
|
|
html += ' <div class="card-header" style="background:' + accentTint + ';"><h3 style="margin:0;font-size:14px;color:' + accent + ';"><i class="fas fa-stethoscope"></i> Auscultation landmarks — APTM + Erb\'s</h3></div>';
|
|
// Image ALWAYS on its own row (full width on every device, responsive
|
|
// max-width caps it so it isn't huge on desktop). Tap/click to open
|
|
// full-size in a new tab — native browser zoom works there. Legend
|
|
// below stacks on narrow screens via auto-fit grid with minmax.
|
|
html += ' <div style="padding:14px 16px 6px;">';
|
|
html += ' <a href="/images/pe-guide/aptm.png" target="_blank" rel="noopener" aria-label="Open APTM diagram full size in a new tab" style="display:block;text-align:center;cursor:zoom-in;">';
|
|
html += ' <img src="/images/pe-guide/aptm.png" alt="APTM cardiac auscultation points diagram" style="max-width:100%;width:100%;max-width:420px;height:auto;display:inline-block;border-radius:8px;border:1px solid var(--g200);"/>';
|
|
html += ' <div style="font-size:11px;color:var(--g500);margin-top:6px;"><i class="fas fa-up-right-from-square" style="margin-right:4px;"></i>Tap / click to open full-size in a new tab (pinch to zoom on mobile)</div>';
|
|
html += ' </a>';
|
|
html += ' </div>';
|
|
html += ' <div style="padding:4px 16px 14px;">';
|
|
html += ' <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:10px 16px;font-size:12px;line-height:1.55;">';
|
|
APTM_LEGEND.forEach(function (p) {
|
|
html += '<div style="display:flex;gap:10px;align-items:flex-start;">';
|
|
html += ' <div style="flex-shrink:0;width:24px;height:24px;border-radius:50%;background:' + p.color + ';color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:12px;">' + p.letter + '</div>';
|
|
html += ' <div style="flex:1;min-width:0;">';
|
|
html += ' <div style="font-weight:600;color:var(--g800);font-size:13px;">' + esc(p.title) + '</div>';
|
|
html += ' <div style="color:var(--g600);font-size:11px;margin-top:1px;">' + esc(p.location) + '</div>';
|
|
html += ' <div style="color:var(--g700);margin-top:3px;font-size:12px;"><strong>Listen for:</strong> ' + esc(p.listen) + '</div>';
|
|
if (p.innocent) {
|
|
html += ' <div style="color:#047857;margin-top:2px;font-size:11px;"><i class="fas fa-leaf" style="margin-right:3px;"></i><em>Innocent:</em> ' + esc(p.innocent) + '</div>';
|
|
}
|
|
html += ' </div>';
|
|
html += '</div>';
|
|
});
|
|
html += ' </div>';
|
|
html += ' </div>';
|
|
|
|
// ─ Cardiac sounds library ─
|
|
html += ' <div style="border-top:1px solid var(--g200);padding:14px;">';
|
|
html += ' <div style="font-weight:600;font-size:13px;color:' + accent + ';margin-bottom:10px;"><i class="fas fa-volume-high"></i> Cardiac sounds library</div>';
|
|
html += ' <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
|
|
CARDIAC_SOUNDS.forEach(function (s) {
|
|
html += renderSoundCard(s);
|
|
});
|
|
html += ' </div>';
|
|
html += ' </div>';
|
|
|
|
// ─ Innocent murmur reference panel ─
|
|
html += ' <div style="border-top:1px solid var(--g200);padding:14px;background:#f0fdf4;">';
|
|
html += ' <div style="font-weight:600;font-size:13px;color:#047857;margin-bottom:8px;"><i class="fas fa-leaf"></i> Classic innocent murmurs (benign, no workup needed)</div>';
|
|
html += ' <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px;">';
|
|
INNOCENT_MURMURS.forEach(function (m) {
|
|
html += '<div style="background:#fff;border:1px solid var(--g200);border-radius:8px;padding:10px 12px;">';
|
|
html += ' <div style="font-weight:600;color:var(--g800);font-size:13px;margin-bottom:3px;">' + esc(m.name) + '</div>';
|
|
html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:4px;">Age: ' + esc(m.age) + ' · Location: ' + esc(m.location) + '</div>';
|
|
html += ' <div style="font-size:12px;color:var(--g700);line-height:1.55;"><strong>Sound:</strong> ' + esc(m.character) + '</div>';
|
|
html += ' <div style="font-size:12px;color:var(--g700);line-height:1.55;margin-top:3px;"><strong>Confirm innocent:</strong> ' + esc(m.confirm) + '</div>';
|
|
html += '</div>';
|
|
});
|
|
html += ' </div>';
|
|
html += ' <div style="margin-top:10px;font-size:11px;color:var(--g600);line-height:1.55;"><strong>The 7 "S" innocent-murmur criteria:</strong> Soft (≤ 2/6) · Systolic · Short · Single (no S3/S4) · Small (non-radiating) · Sweet (musical) · Sensitive to position/respiration. Any murmur breaking this pattern — diastolic, ≥ grade 3, radiating, continuous, accompanied by symptoms — deserves pediatric cardiology referral.</div>';
|
|
html += ' </div>';
|
|
html += '</div>';
|
|
}
|
|
|
|
// ─ Respiratory sounds library (only for resp system) ─
|
|
if (currentSystem === 'resp') {
|
|
html += renderSoundsLibrary('Respiratory sounds library', RESP_SOUNDS, accent, accentTint, 'fa-lungs');
|
|
}
|
|
|
|
// ─ Grading scales reference (collapsible) ─
|
|
var scaleKeys = SYSTEM_SCALES[currentSystem] || [];
|
|
if (scaleKeys.length) {
|
|
html += '<details class="card" style="margin-bottom:14px;background:' + accentTint + ';border:1px solid ' + accent + '33;">';
|
|
html += ' <summary style="padding:10px 16px;cursor:pointer;font-weight:600;font-size:13px;color:' + accent + ';list-style:none;display:flex;align-items:center;gap:8px;"><i class="fas fa-book-medical"></i> Grading scales & reference <span style="font-weight:400;color:var(--g500);font-size:11px;">(click to expand)</span></summary>';
|
|
html += ' <div style="padding:0 16px 14px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px;">';
|
|
scaleKeys.forEach(function (sk) {
|
|
var sc = SCALES[sk]; if (!sc) return;
|
|
html += '<div style="background:#fff;border:1px solid var(--g200);border-radius:8px;padding:10px 12px;">';
|
|
html += ' <div style="font-weight:600;font-size:12px;color:var(--g800);margin-bottom:6px;"><i class="fas ' + sc.icon + '" style="color:' + accent + ';margin-right:6px;"></i>' + esc(sc.title) + '</div>';
|
|
html += ' <table style="width:100%;font-size:11px;line-height:1.5;border-collapse:collapse;">';
|
|
sc.rows.forEach(function (row) {
|
|
html += '<tr><td style="padding:2px 0;white-space:nowrap;color:' + accent + ';font-weight:600;font-family:ui-monospace,monospace;">' + esc(row[0]) + '</td><td style="padding:2px 0 2px 10px;color:var(--g700);">' + esc(row[1]) + '</td></tr>';
|
|
});
|
|
html += ' </table>';
|
|
html += '</div>';
|
|
});
|
|
html += ' </div>';
|
|
html += '</details>';
|
|
}
|
|
|
|
// ─ Components ─
|
|
section.components.forEach(function (c, ci) {
|
|
html += '<div class="card" style="margin-bottom:12px;border-left:3px solid ' + accent + ';">';
|
|
|
|
// Header
|
|
html += ' <div style="padding:12px 16px 8px;display:flex;align-items:center;justify-content:space-between;gap:10px;">';
|
|
html += ' <h3 style="margin:0;font-size:15px;color:var(--g800);">' + esc(c.name) + '</h3>';
|
|
html += ' <span style="font-size:10px;font-weight:600;padding:3px 8px;border-radius:10px;background:' + accent + '22;color:' + accent + ';text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap;">' + c.steps.length + ' steps</span>';
|
|
html += ' </div>';
|
|
|
|
// Significance line
|
|
if (c.significance) {
|
|
html += ' <div style="padding:0 16px 6px;font-size:12px;color:var(--g600);line-height:1.55;"><i class="fas fa-bullseye" style="color:' + accent + ';margin-right:6px;font-size:11px;"></i>' + esc(c.significance) + '</div>';
|
|
}
|
|
|
|
// Teaching pearl
|
|
if (c.pearl) {
|
|
html += ' <div style="margin:6px 16px 10px;padding:10px 12px;background:#fef7e7;border-left:3px solid #f59e0b;border-radius:0 6px 6px 0;font-size:12px;line-height:1.6;color:#78350f;"><i class="fas fa-lightbulb" style="color:#f59e0b;margin-right:6px;"></i><em>' + esc(c.pearl) + '</em></div>';
|
|
}
|
|
|
|
// Steps
|
|
html += ' <div style="padding:4px 12px 10px;">';
|
|
c.steps.forEach(function (s, si) {
|
|
var key = currentSystem + '-' + ci + '-' + si;
|
|
if (!state[key]) state[key] = { component: c.name, label: s.label, method: s.method, normal: s.normal, status: null, note: '' };
|
|
var st = state[key].status;
|
|
var rowBg = st === 'normal' ? '#ecfdf5' : st === 'abnormal' ? '#fef2f2' : 'transparent';
|
|
var rowBorder = st === 'normal' ? '#a7f3d0' : st === 'abnormal' ? '#fecaca' : 'var(--g200)';
|
|
html += '<div class="pe-step" data-pe-key="' + key + '" style="padding:10px 12px;border:1px solid ' + rowBorder + ';border-radius:8px;margin-bottom:6px;background:' + rowBg + ';transition:background 0.12s,border-color 0.12s;">';
|
|
html += ' <div style="display:flex;align-items:flex-start;gap:10px;">';
|
|
html += ' <div style="flex-shrink:0;width:22px;height:22px;border-radius:50%;background:' + accent + '22;color:' + accent + ';font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;line-height:1;">' + (si + 1) + '</div>';
|
|
html += ' <div style="flex:1;min-width:0;">';
|
|
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);">' + esc(s.label) + '</div>';
|
|
html += ' <div style="font-size:12px;color:var(--g600);margin-top:3px;line-height:1.55;"><span style="color:var(--g500);font-weight:600;text-transform:uppercase;letter-spacing:0.5px;font-size:10px;">How</span> ' + esc(s.method) + '</div>';
|
|
html += ' <div style="font-size:12px;color:var(--g600);margin-top:2px;line-height:1.55;"><span style="color:#059669;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;font-size:10px;">Normal</span> ' + esc(s.normal) + '</div>';
|
|
html += ' </div>';
|
|
html += ' <div style="display:flex;gap:3px;flex-shrink:0;">';
|
|
html += ' <button class="visit-status-btn ' + (st === 'normal' ? 'done' : '') + '" data-pe-status="normal" data-pe-key="' + key + '" title="Normal">✓</button>';
|
|
html += ' <button class="visit-status-btn ' + (st === 'abnormal' ? 'refused' : '') + '" data-pe-status="abnormal" data-pe-key="' + key + '" title="Abnormal">✗</button>';
|
|
html += ' <button class="visit-status-btn ' + (st === null ? 'not-due' : '') + '" data-pe-status="skip" data-pe-key="' + key + '" title="Skip">—</button>';
|
|
html += ' </div>';
|
|
html += ' </div>';
|
|
html += ' <div class="pe-abnormal-detail" style="margin-top:8px;' + (st === 'abnormal' ? '' : 'display:none;') + '">';
|
|
html += ' <input type="text" class="pe-note" data-pe-key="' + key + '" placeholder="Describe the abnormal finding (e.g., 4/5 strength, left pronator drift)" value="' + esc(state[key].note || '') + '" style="width:100%;padding:6px 10px;font-size:12px;border:1px solid #fecaca;border-radius:6px;background:#fff;">';
|
|
html += ' </div>';
|
|
html += '</div>';
|
|
});
|
|
html += ' </div>';
|
|
|
|
// Watch-for
|
|
if (c.abnormalHints && c.abnormalHints.length) {
|
|
html += '<div style="margin:0 12px 12px;padding:10px 12px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:0 6px 6px 0;font-size:11px;line-height:1.7;color:#7f1d1d;">';
|
|
html += '<div style="font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#991b1b;margin-bottom:4px;font-size:10px;"><i class="fas fa-triangle-exclamation"></i> Watch for</div>';
|
|
html += c.abnormalHints.map(esc).join(' · ');
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
});
|
|
|
|
content.innerHTML = html;
|
|
|
|
content.querySelectorAll('[data-pe-status]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () { handleStatus(btn); });
|
|
});
|
|
content.querySelectorAll('.pe-note').forEach(function (inp) {
|
|
inp.addEventListener('input', function () {
|
|
var key = inp.dataset.peKey;
|
|
if (state[key]) state[key].note = inp.value;
|
|
});
|
|
});
|
|
// Single-playback policy: when any <audio> starts, pause every other one.
|
|
content.querySelectorAll('.pe-audio').forEach(function (a) {
|
|
a.addEventListener('play', function () {
|
|
content.querySelectorAll('.pe-audio').forEach(function (other) {
|
|
if (other !== a && !other.paused) other.pause();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function handleStatus(btn) {
|
|
var key = btn.dataset.peKey;
|
|
var status = btn.dataset.peStatus;
|
|
if (!state[key]) return;
|
|
state[key].status = (status === 'skip') ? null : status;
|
|
var row = btn.closest('.pe-step');
|
|
if (row) {
|
|
var s = state[key].status;
|
|
row.style.background = s === 'normal' ? '#ecfdf5' : s === 'abnormal' ? '#fef2f2' : 'transparent';
|
|
row.style.borderColor = s === 'normal' ? '#a7f3d0' : s === 'abnormal' ? '#fecaca' : 'var(--g200)';
|
|
row.querySelectorAll('[data-pe-status]').forEach(function (b) {
|
|
b.classList.remove('done', 'refused', 'not-due');
|
|
if (b.dataset.peStatus === 'normal' && state[key].status === 'normal') b.classList.add('done');
|
|
if (b.dataset.peStatus === 'abnormal' && state[key].status === 'abnormal') b.classList.add('refused');
|
|
if (b.dataset.peStatus === 'skip' && state[key].status === null) b.classList.add('not-due');
|
|
});
|
|
var detail = row.querySelector('.pe-abnormal-detail');
|
|
if (detail) detail.style.display = (state[key].status === 'abnormal') ? '' : 'none';
|
|
}
|
|
}
|
|
|
|
function setAllNormal() {
|
|
Object.keys(state).forEach(function (k) { if (k.indexOf(currentSystem + '-') === 0) state[k].status = 'normal'; });
|
|
renderSystem();
|
|
}
|
|
function clearAll() {
|
|
Object.keys(state).forEach(function (k) { if (k.indexOf(currentSystem + '-') === 0) { state[k].status = null; state[k].note = ''; } });
|
|
renderSystem();
|
|
}
|
|
|
|
function generate() {
|
|
if (!currentAgeGroup || !PE_DATA[currentAgeGroup]) return;
|
|
var steps = Object.keys(state)
|
|
.filter(function (k) { return k.indexOf(currentSystem + '-') === 0; })
|
|
.map(function (k) { return state[k]; });
|
|
|
|
var assessed = steps.filter(function (s) { return s.status !== null; });
|
|
if (assessed.length === 0) { showToast('Mark at least one step Normal or Abnormal', 'error'); return; }
|
|
|
|
var btn = document.getElementById('pe-generate-btn');
|
|
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...';
|
|
|
|
var modelSel = document.querySelector('#peguide-tab .tab-model-select');
|
|
var model = modelSel ? modelSel.value : '';
|
|
var format = document.getElementById('pe-format').value;
|
|
|
|
fetch('/api/generate-pe-narrative', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({
|
|
steps: steps,
|
|
ageGroup: PE_DATA[currentAgeGroup].label,
|
|
system: currentSystem,
|
|
patientAge: document.getElementById('pe-age').value,
|
|
patientGender: document.getElementById('pe-gender').value,
|
|
model: model,
|
|
format: format
|
|
})
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
|
|
var out = document.getElementById('pe-output');
|
|
out.classList.remove('hidden');
|
|
document.getElementById('pe-narrative-text').textContent = data.narrative;
|
|
var tag = document.getElementById('pe-model-tag');
|
|
if (tag) tag.textContent = data.model || '';
|
|
var bar = document.getElementById('pe-summary-bar');
|
|
if (bar && data.summary) {
|
|
bar.classList.remove('hidden');
|
|
bar.innerHTML = '<span style="color:#10b981;">' + data.summary.normal + ' normal</span> · <span style="color:#ef4444;">' + data.summary.abnormal + ' abnormal</span> · <span style="color:#9ca3af;">' + data.summary.notAssessed + ' skipped</span>';
|
|
}
|
|
})
|
|
.catch(function (err) { console.error('[PEGuide] generate error', err); showToast('Request failed', 'error'); })
|
|
.finally(function () {
|
|
btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Exam Report';
|
|
});
|
|
}
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|