chore: delete vanilla tree — React+Tailwind is the only client now
Closes the migration. Every line of vanilla JS/CSS/HTML that used to
render the user-facing app is gone. The React bundle in public/app/
is the entire client.
Deleted (everything was 100% covered by ported React equivalents):
• public/index.html (vanilla shell — auth screen + tab loader)
• public/js/ (~30 vanilla modules: app.js, auth.js,
admin.js, calculators.js, peGuide.js,
bedside/*, learningHub.js, encounters.js,
etc. — all replaced by client/src/pages/*
and client/src/data/*)
• public/components/ (18 lazy-loaded HTML fragments — not
loaded by any React route)
• public/css/styles.css (vanilla design system — superseded by
Tailwind + shadcn classes throughout
the React tree)
• public/e2e-harness.html (vanilla-only Playwright bootstrapper)
• e2e/tests/bedside-smoke.spec.js
• e2e/tests/top-calculators.spec.js
(the two e2e specs that exercised
/e2e-harness.html and the vanilla
calculators directly — replaced by
calculators-react.spec.js +
bedside-react.spec.js + the 136
vitest parity tests in
shared/clinical/*.test.ts)
Moved (still needed by the backend):
• public/js/pediatricScheduleData.js → src/data/pediatric-schedule-data.js
Required by src/routes/wellVisit.ts at runtime; should never have been
in public/ anyway since it carries server-side schedule + growth +
BMI reference tables and was being served as a 2120-line public JS
blob to every browser. Not in public/ means it's not exposed to
anonymous web requests anymore.
server.ts
• Removed the dead getTemplatedIndex() / INDEX_PATH machinery that
used to BUILD_ID-stamp /js/* and /css/* references in the vanilla
HTML. Vite's hashed asset URLs already do that job for the React
bundle.
• express.static cache header: removed the /components/ branch
(folder no longer exists) and changed /js/ + /css/ from 1-hour
to 1-day immutable cache — safe because Vite hashes the
filenames on every build.
Backend tsc + client tsc + vite build all green. 136/136 vitest
parity tests still pass against the captured calc-vectors.json.
Initial bundle unchanged at 343.97 kB / 106.59 kB gz.
This commit is contained in:
parent
1be74d453e
commit
39d764e1ff
81 changed files with 8 additions and 24695 deletions
|
|
@ -1,161 +0,0 @@
|
|||
// Bedside module smoke tests.
|
||||
// The harness renders the Calculators + Bedside components side-by-side, so
|
||||
// each test selects the target sub-pill (if any) and asserts a known string
|
||||
// is rendered. Bedside was promoted to a top-level tab, so there is no longer
|
||||
// a calc-nav-pill[data-calc="bedside"] — the panel is always visible in the
|
||||
// harness and always the full tab in the live app.
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function openCalculators(page) {
|
||||
await page.goto('/e2e-harness.html');
|
||||
await page.waitForFunction(() => window.__harnessReady === true);
|
||||
// Wait for the bedside component to finish injecting — #bedside-age is the
|
||||
// first input in the shared age→weight estimator at the top of the tab.
|
||||
await page.waitForSelector('#bedside-age');
|
||||
}
|
||||
|
||||
async function openBedside(page, subPill) {
|
||||
await openCalculators(page);
|
||||
if (subPill) {
|
||||
await page.click(`button.calc-pill[data-em="${subPill}"]`);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Bedside — top-level', () => {
|
||||
test('Calculators tab shows age-weight estimator', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await expect(page.locator('#bedside-age')).toBeVisible();
|
||||
await expect(page.locator('#bedside-formula')).toBeVisible();
|
||||
await expect(page.locator('#bedside-weight')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Age → Weight: typing 3y auto-fills weight (APLS)', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await page.fill('#bedside-age', '3y');
|
||||
await expect(page.locator('#bedside-weight')).toHaveValue('14');
|
||||
await expect(page.locator('#bedside-estimate-note')).toContainText('APLS');
|
||||
});
|
||||
|
||||
test('Formula switch to Best Guess updates weight', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await page.fill('#bedside-age', '3y');
|
||||
await page.selectOption('#bedside-formula', 'bestguess');
|
||||
await expect(page.locator('#bedside-weight')).toHaveValue('16'); // 2 * (3+5) = 16
|
||||
await expect(page.locator('#bedside-estimate-note')).toContainText('Best Guess');
|
||||
});
|
||||
|
||||
test('Clear button resets the estimator', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await page.fill('#bedside-age', '5y');
|
||||
await page.click('#btn-bedside-clear');
|
||||
await expect(page.locator('#bedside-age')).toHaveValue('');
|
||||
await expect(page.locator('#bedside-weight')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Bedside — every sub-pill renders', () => {
|
||||
const subPills = [
|
||||
{ key: 'neonatal', expected: /Neonatal Assessment/i },
|
||||
{ key: 'airway', expected: /Airway Management/i },
|
||||
{ key: 'cardiac', expected: /Cardiac Arrest/i },
|
||||
{ key: 'respiratory', expected: /Respiratory Management/i },
|
||||
{ key: 'ventilation', expected: /Oxygen & Ventilation/i },
|
||||
{ key: 'seizure', expected: /Status Epilepticus/i },
|
||||
{ key: 'sepsis', expected: /Sepsis & Fever/i },
|
||||
{ key: 'anaphylaxis', expected: /Anaphylaxis/i },
|
||||
{ key: 'sedation', expected: /Procedural Sedation/i },
|
||||
{ key: 'agitation', expected: /Acute Agitation/i },
|
||||
{ key: 'antiemetics', expected: /Antiemetics/i },
|
||||
{ key: 'antimicrobials', expected: /Empiric Antimicrobials/i },
|
||||
{ key: 'burns', expected: /Burn Management/i },
|
||||
{ key: 'toxicology', expected: /Toxicology/i },
|
||||
{ key: 'trauma', expected: /Trauma/i },
|
||||
];
|
||||
|
||||
for (const { key, expected } of subPills) {
|
||||
test(`${key} sub-pill shows header`, async ({ page }) => {
|
||||
await openBedside(page, key);
|
||||
await expect(page.locator(`#em-${key}`)).toContainText(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Bedside — dose calculators fire', () => {
|
||||
test('Status Epilepticus: Show Pathway renders timeline with weight', async ({ page }) => {
|
||||
await openBedside(page, 'seizure');
|
||||
await page.fill('#seizure-weight', '20');
|
||||
await page.click('#btn-seizure-calc');
|
||||
await expect(page.locator('#seizure-result')).toContainText(/20 kg/);
|
||||
await expect(page.locator('#seizure-result')).toContainText(/Lorazepam/);
|
||||
await expect(page.locator('#seizure-result')).toContainText(/0\.1 mg\/kg/); // per-kg visible
|
||||
});
|
||||
|
||||
test('Sepsis: Show Approach renders Phoenix criteria + first-hour bundle', async ({ page }) => {
|
||||
await openBedside(page, 'sepsis');
|
||||
await page.fill('#sepsis-weight', '25');
|
||||
await page.click('#btn-sepsis-show');
|
||||
await expect(page.locator('#sepsis-result')).toContainText(/Phoenix/);
|
||||
await expect(page.locator('#sepsis-result')).toContainText(/first-hour/i);
|
||||
});
|
||||
|
||||
test('Anaphylaxis: Calculate Doses shows weight-based epinephrine', async ({ page }) => {
|
||||
await openBedside(page, 'anaphylaxis');
|
||||
await page.fill('#anaph-weight', '25');
|
||||
await page.click('#btn-anaph-calc');
|
||||
await expect(page.locator('#anaph-result')).toContainText(/Epinephrine/);
|
||||
await expect(page.locator('#anaph-result')).toContainText(/0\.25 mg/); // 25*0.01
|
||||
});
|
||||
|
||||
test('Burns: body-parts calculator + Parkland', async ({ page }) => {
|
||||
await openBedside(page, 'burns');
|
||||
await page.fill('#burn-weight', '20');
|
||||
await page.fill('input[data-burn-region="head"]', '50'); // 50% of 13 (young) = 6.5
|
||||
await page.fill('input[data-burn-region="ant_trunk"]', '100'); // 100% of 13 = 13
|
||||
await page.click('#btn-burn-calc');
|
||||
await expect(page.locator('#burn-result')).toContainText(/Parkland/);
|
||||
await expect(page.locator('#burn-result')).toContainText(/20 kg/);
|
||||
});
|
||||
|
||||
test('Airway: Calculate renders RSI drugs with per-kg', async ({ page }) => {
|
||||
await openBedside(page, 'airway');
|
||||
await page.fill('#airway-weight', '20');
|
||||
await page.fill('#airway-age', '5');
|
||||
await page.click('#btn-airway-calc');
|
||||
await expect(page.locator('#airway-result')).toContainText(/Ketamine/);
|
||||
await expect(page.locator('#airway-result')).toContainText(/mg\/kg/); // per-kg visible
|
||||
await expect(page.locator('#airway-result')).toContainText(/ETT/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Bedside — interactive widgets', () => {
|
||||
test('Lightbox: seizure pathway image opens and closes', async ({ page }) => {
|
||||
await openBedside(page, 'seizure');
|
||||
await page.click('button[data-img-src="/img/epilepsy_eiic_pathway.png"]');
|
||||
await expect(page.locator('#img-lightbox')).toBeVisible();
|
||||
await expect(page.locator('#img-lightbox-img')).toHaveAttribute('src', /epilepsy_eiic_pathway/);
|
||||
await page.click('#img-lightbox-close');
|
||||
await expect(page.locator('#img-lightbox')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Lightbox: NRP pathway image opens on neonatal sub-pill', async ({ page }) => {
|
||||
await openBedside(page, 'neonatal');
|
||||
// The "View pathway image" button sits inside a collapsed <details>
|
||||
// titled "NRP Resuscitation Pathway" — expand it first.
|
||||
await page.getByText('NRP Resuscitation Pathway').click();
|
||||
await page.click('button[data-img-src="/img/nrp_pathway.png"]');
|
||||
await expect(page.locator('#img-lightbox')).toBeVisible();
|
||||
await expect(page.locator('#img-lightbox-img')).toHaveAttribute('src', /nrp_pathway/);
|
||||
await page.click('#img-lightbox-close');
|
||||
await expect(page.locator('#img-lightbox')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Ventilation: Show Reference renders pressure-time SVG', async ({ page }) => {
|
||||
await openBedside(page, 'ventilation');
|
||||
await page.fill('#vent-weight', '20');
|
||||
await page.click('#btn-vent-show');
|
||||
await expect(page.locator('#vent-result svg')).toBeVisible();
|
||||
await expect(page.locator('#vent-result')).toContainText(/Target SpO2/);
|
||||
await expect(page.locator('#vent-result')).toContainText(/PEEP/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
// Smoke tests for the 10 top-row calculator tabs (everything except Bedside).
|
||||
// Each test navigates to its tab, fills inputs, clicks Calculate/Assess,
|
||||
// and asserts a known string appears in the result. Catches the "tab loads
|
||||
// but button does nothing" class of regression.
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function openCalculators(page) {
|
||||
await page.goto('/e2e-harness.html');
|
||||
await page.waitForFunction(() => window.__harnessReady === true);
|
||||
await page.waitForSelector('button.calc-nav-pill[data-calc="bp"]');
|
||||
}
|
||||
|
||||
async function selectTab(page, tabName) {
|
||||
await page.click(`button.calc-nav-pill[data-calc="${tabName}"]`);
|
||||
await expect(page.locator(`#calc-${tabName}`)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Top-level calculators — panel loads', () => {
|
||||
const tabs = ['bp', 'bmi', 'growth', 'bili', 'vitals', 'bsa', 'dose', 'resus', 'gcs', 'equipment'];
|
||||
for (const tab of tabs) {
|
||||
test(`${tab} panel becomes visible when pill clicked`, async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, tab);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Blood Pressure percentile', () => {
|
||||
test('5 yr, male, height 110, BP 105/65 → produces a result', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'bp');
|
||||
await page.fill('#bp-age', '5');
|
||||
await page.selectOption('#bp-sex', 'male');
|
||||
await page.fill('#bp-height', '110');
|
||||
await page.fill('#bp-systolic', '105');
|
||||
await page.fill('#bp-diastolic', '65');
|
||||
await page.click('#btn-calc-bp');
|
||||
await expect(page.locator('#bp-result')).not.toHaveClass(/hidden/);
|
||||
// Result should mention a percentile or classification
|
||||
await expect(page.locator('#bp-result')).toContainText(/percentile|Normal|Elevated|HTN|Stage/i);
|
||||
});
|
||||
|
||||
test('Clear button hides result', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'bp');
|
||||
await page.fill('#bp-age', '5');
|
||||
await page.selectOption('#bp-sex', 'male');
|
||||
await page.fill('#bp-height', '110');
|
||||
await page.fill('#bp-systolic', '105');
|
||||
await page.fill('#bp-diastolic', '65');
|
||||
await page.click('#btn-calc-bp');
|
||||
await page.click('#btn-clear-bp');
|
||||
await expect(page.locator('#bp-result')).toHaveClass(/hidden/);
|
||||
await expect(page.locator('#bp-age')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('BMI percentile', () => {
|
||||
test('7 yr, male, 25 kg, 120 cm → BMI computed', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'bmi');
|
||||
await page.fill('#bmi-age-yr', '7');
|
||||
await page.selectOption('#bmi-age-mo', '0');
|
||||
await page.selectOption('#bmi-sex', 'male');
|
||||
await page.fill('#bmi-weight', '25');
|
||||
await page.fill('#bmi-height', '120');
|
||||
await page.click('#btn-calc-bmi');
|
||||
await expect(page.locator('#bmi-result')).not.toHaveClass(/hidden/);
|
||||
// BMI = 25 / 1.20² = 17.36 — expect something recognizable
|
||||
await expect(page.locator('#bmi-result')).toContainText(/17\.|BMI|percentile/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Body Surface Area (Mosteller)', () => {
|
||||
test('20 kg, 110 cm → BSA shown', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'bsa');
|
||||
await page.fill('#bsa-weight', '20');
|
||||
await page.fill('#bsa-height', '110');
|
||||
await page.click('#btn-calc-bsa');
|
||||
await expect(page.locator('#bsa-result')).not.toHaveClass(/hidden/);
|
||||
// sqrt(110*20/3600) = 0.782
|
||||
await expect(page.locator('#bsa-result')).toContainText(/0\.78|BSA|m²|m2/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Weight-based dosing', () => {
|
||||
test('15 kg × 10 mg/kg → 150 mg shown', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'dose');
|
||||
await page.fill('#dose-weight', '15');
|
||||
await page.fill('#dose-per-kg', '10');
|
||||
await page.click('#btn-calc-dose');
|
||||
await expect(page.locator('#dose-result')).not.toHaveClass(/hidden/);
|
||||
await expect(page.locator('#dose-result')).toContainText(/150/);
|
||||
});
|
||||
|
||||
test('Max cap respected: 15 kg × 100 mg/kg capped at 500 mg', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'dose');
|
||||
await page.fill('#dose-weight', '15');
|
||||
await page.fill('#dose-per-kg', '100');
|
||||
await page.fill('#dose-max', '500');
|
||||
await page.click('#btn-calc-dose');
|
||||
await expect(page.locator('#dose-result')).toContainText(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Growth charts', () => {
|
||||
test('3 yr male, 14 kg → Weight-for-Age result', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'growth');
|
||||
await page.selectOption('#growth-sex', 'male');
|
||||
await page.fill('#growth-age-yr', '3');
|
||||
await page.fill('#growth-weight', '14');
|
||||
await page.click('#btn-calc-growth');
|
||||
await expect(page.locator('#growth-result')).not.toHaveClass(/hidden/);
|
||||
await expect(page.locator('#growth-result')).toContainText(/percentile|z-score|%ile|z=/i);
|
||||
});
|
||||
|
||||
test('Sub-pill switches to Length-for-Age', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'growth');
|
||||
await page.click('button.calc-pill[data-growth="lfa"]');
|
||||
await expect(page.locator('button.calc-pill[data-growth="lfa"]')).toHaveClass(/active/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Bilirubin (AAP 2022)', () => {
|
||||
test('GA 38, age 48h, TSB 12.5, no risk factors → AAP assessment', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'bili');
|
||||
await page.selectOption('#bili-ga', '38');
|
||||
await page.fill('#bili-age-hours', '48');
|
||||
await page.fill('#bili-tsb', '12.5');
|
||||
await page.selectOption('#bili-risk', 'none');
|
||||
await page.click('#btn-calc-bili-aap');
|
||||
await expect(page.locator('#bili-result')).not.toHaveClass(/hidden/);
|
||||
await expect(page.locator('#bili-result')).toContainText(/phototherapy|threshold|AAP|bilirubin/i);
|
||||
});
|
||||
|
||||
test('Bhutani sub-pill: age 48h, TSB 8.5 → risk zone', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'bili');
|
||||
await page.click('button.calc-pill[data-bili="bhutani"]');
|
||||
await page.fill('#bhutani-age', '48');
|
||||
await page.fill('#bhutani-tsb', '8.5');
|
||||
await page.click('#btn-calc-bhutani');
|
||||
await expect(page.locator('#bili-result')).not.toHaveClass(/hidden/);
|
||||
await expect(page.locator('#bili-result')).toContainText(/risk|zone|low|high|intermediate/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Vital signs reference', () => {
|
||||
test('Selecting 1-3 yr shows HR range', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'vitals');
|
||||
await page.selectOption('#vitals-age-select', '1-3yr');
|
||||
await expect(page.locator('#vitals-result')).not.toHaveClass(/hidden/);
|
||||
// Expected: HR 70-110
|
||||
await expect(page.locator('#vitals-result')).toContainText(/70|HR/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Resus meds', () => {
|
||||
test('15 kg → multiple weight-based drug doses rendered', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'resus');
|
||||
await page.fill('#resus-weight', '15');
|
||||
await page.click('#btn-calc-resus');
|
||||
await expect(page.locator('#resus-result')).not.toHaveClass(/hidden/);
|
||||
// At least epinephrine + atropine should appear for any resus dose table
|
||||
await expect(page.locator('#resus-result')).toContainText(/epinephrine|epi/i);
|
||||
await expect(page.locator('#resus-result')).toContainText(/atropine/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Glasgow Coma Scale', () => {
|
||||
test('Child defaults (4/5/6) → GCS 15 shown', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'gcs');
|
||||
// selects default to max scores → 4+5+6 = 15
|
||||
await expect(page.locator('#gcs-result')).toContainText(/15/);
|
||||
});
|
||||
|
||||
test('Changing motor to "None" (1) → GCS drops', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'gcs');
|
||||
await page.selectOption('#gcs-child-motor', '1');
|
||||
await expect(page.locator('#gcs-result')).toContainText(/10/); // 4+5+1
|
||||
});
|
||||
|
||||
test('Switch to infant panel', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'gcs');
|
||||
await page.click('button.calc-pill[data-gcs="infant"]');
|
||||
await expect(page.locator('#gcs-infant-panel')).toBeVisible();
|
||||
await expect(page.locator('#gcs-child-panel')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Equipment sizing', () => {
|
||||
test('Selecting "1 year" renders sizes', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'equipment');
|
||||
await page.selectOption('#equip-age-select', '1yr');
|
||||
await expect(page.locator('#equip-result')).not.toHaveClass(/hidden/);
|
||||
// Should mention ETT and at least one other item
|
||||
await expect(page.locator('#equip-result')).toContainText(/ETT|Endotracheal/i);
|
||||
});
|
||||
|
||||
test('Selecting empty option hides result', async ({ page }) => {
|
||||
await openCalculators(page);
|
||||
await selectTab(page, 'equipment');
|
||||
await page.selectOption('#equip-age-select', '1yr');
|
||||
await page.selectOption('#equip-age-select', '');
|
||||
await expect(page.locator('#equip-result')).toHaveClass(/hidden/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,422 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-user-shield"></i> Admin Panel</h2>
|
||||
<p>Manage users, app settings, and view usage statistics</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="admin-stats" id="admin-stats">
|
||||
<div class="stat-card"><div class="stat-value" id="stat-users">—</div><div class="stat-label">Total Users</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="stat-api-total">—</div><div class="stat-label">Total API Calls</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="stat-api-today">—</div><div class="stat-label">API Calls Today</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Registration -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-door-open"></i> Registration</h3></div>
|
||||
<div style="padding:16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
|
||||
<span id="reg-status-text" style="font-size:13px;color:var(--g600);">Loading...</span>
|
||||
<button id="btn-toggle-reg" class="btn-sm btn-primary">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Users</h3>
|
||||
<button id="btn-refresh-users" class="btn-sm btn-ghost"><i class="fas fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="admin-table" id="admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-users-body">
|
||||
<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Announcement Banner ──────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-bullhorn"></i> Announcement Banner</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Show banner:</label>
|
||||
<select id="cms-ann-enabled" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="false">Hidden</option>
|
||||
<option value="true">Visible</option>
|
||||
</select>
|
||||
<select id="cms-ann-type" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="info">Info (blue)</option>
|
||||
<option value="warning">Warning (amber)</option>
|
||||
<option value="error">Error (red)</option>
|
||||
<option value="success">Success (green)</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea id="cms-ann-text" rows="2" style="font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;" placeholder="Announcement message..."></textarea>
|
||||
<div>
|
||||
<button id="btn-save-announcement" class="btn-sm btn-primary">Save & Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Feature Flags ─────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-toggle-on"></i> Feature Flags</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:10px;" id="cms-feature-flags">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Read Aloud (TTS):</label>
|
||||
<select id="cms-flag-read-aloud" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Nextcloud Integration:</label>
|
||||
<select id="cms-flag-nextcloud" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button id="btn-save-flags" class="btn-sm btn-primary">Save Flags</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── OIDC / SSO Configuration ──────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-shield-halved"></i> Single Sign-On (OIDC)</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0;">Configure OpenID Connect for SSO with Azure AD, Okta, Keycloak, PocketID, Google, etc.<br>Callback URL: <code style="font-size:11px;background:var(--g100);padding:2px 6px;border-radius:4px;" id="oidc-callback-url"></code></p>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Enable SSO:</label>
|
||||
<select id="oidc-enabled" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Issuer URL:</label>
|
||||
<input type="url" id="oidc-issuer" placeholder="https://id.example.com" style="flex:1;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;max-width:400px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Client ID:</label>
|
||||
<input type="text" id="oidc-client-id" placeholder="your-client-id" style="flex:1;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;max-width:400px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Client Secret:</label>
|
||||
<input type="password" id="oidc-client-secret" placeholder="Leave blank to keep current" style="flex:1;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;max-width:400px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Button Label:</label>
|
||||
<input type="text" id="oidc-button-label" placeholder="Sign in with SSO" style="flex:1;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;max-width:400px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Disable local login:</label>
|
||||
<select id="oidc-disable-local" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="false">No (both SSO and local login)</option>
|
||||
<option value="true">Yes (SSO only)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button id="btn-save-oidc" class="btn-sm btn-primary"><i class="fas fa-floppy-disk"></i> Save OIDC Settings</button>
|
||||
<span id="oidc-save-status" style="font-size:12px;margin-left:8px;color:var(--g500);"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Email Templates ───────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-envelope"></i> Email Templates</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:16px;">
|
||||
<!-- Email template selector -->
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Template:</label>
|
||||
<select id="cms-email-template" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="verify">Email Verification</option>
|
||||
<option value="reset">Password Reset</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:4px;display:block;">Subject</label>
|
||||
<input type="text" id="cms-email-subject" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Email subject...">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:4px;display:block;">Body (plain text — a button link will be appended automatically)</label>
|
||||
<textarea id="cms-email-body" rows="4" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Email body text..."></textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button id="btn-save-email" class="btn-sm btn-primary">Save Template</button>
|
||||
<button id="btn-test-email" class="btn-sm btn-ghost"><i class="fas fa-paper-plane"></i> Send Test</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: AI Prompts ────────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-robot"></i> AI Prompts</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Changes take effect immediately, no restart needed</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Prompt:</label>
|
||||
<select id="cms-prompt-select" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;max-width:320px;"></select>
|
||||
</div>
|
||||
<textarea id="cms-prompt-text" rows="10" style="width:100%;font-size:12px;font-family:monospace;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Loading..."></textarea>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button id="btn-save-prompt" class="btn-sm btn-primary">Save Prompt</button>
|
||||
<button id="btn-reset-prompt" class="btn-sm btn-ghost"><i class="fas fa-rotate-left"></i> Reset to Default</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: SMTP Email Server ─────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-server"></i> SMTP Email Server</h3>
|
||||
<span id="smtp-source-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">SMTP Host</label>
|
||||
<input type="text" id="cms-smtp-host" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="smtp.gmail.com">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Port</label>
|
||||
<input type="number" id="cms-smtp-port" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="587" value="587">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Username / Email</label>
|
||||
<input type="text" id="cms-smtp-user" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="user@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Password / App Password</label>
|
||||
<input type="password" id="cms-smtp-pass" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Leave blank to keep current">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">From Address</label>
|
||||
<input type="text" id="cms-smtp-from" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Pediatric Scribe <noreply@example.com>">
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:8px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;">TLS/SSL (port 465):</label>
|
||||
<select id="cms-smtp-secure" style="font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="false">STARTTLS (587)</option>
|
||||
<option value="true">SSL/TLS (465)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button id="btn-save-smtp" class="btn-sm btn-primary">Save SMTP</button>
|
||||
<button id="btn-clear-smtp" class="btn-sm btn-ghost"><i class="fas fa-trash"></i> Clear DB (use env)</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0;"><i class="fas fa-info-circle"></i> Environment variables take precedence over DB settings. Storing password in DB is convenient but consider using env vars for production security.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Saved Encounters (Admin View) ─────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-database"></i> Saved Encounters (Site-wide)</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Auto-deleted after <span id="admin-auto-delete-days">7</span> days</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Auto-delete after:</label>
|
||||
<select id="cms-auto-delete-days" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
<option value="7" selected>7 days</option>
|
||||
<option value="14">14 days</option>
|
||||
<option value="30">30 days</option>
|
||||
</select>
|
||||
<button id="btn-save-auto-delete" class="btn-sm btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── AI Model Management ─────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-microchip"></i> AI Model Management</h3>
|
||||
<span id="admin-model-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
|
||||
<!-- Default Model -->
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:120px;">Default Model:</label>
|
||||
<select id="admin-default-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:400px;"></select>
|
||||
<button id="btn-save-default-model" class="btn-sm btn-primary">Set Default</button>
|
||||
</div>
|
||||
|
||||
<!-- Built-in Models Toggle -->
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Built-in Models (toggle to enable/disable for users)</label>
|
||||
<div id="admin-builtin-models" style="display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;padding-right:4px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discover Models from API -->
|
||||
<div style="border-top:1px solid var(--g100);padding-top:14px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Discover Models from Provider API</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-model-search" placeholder="Search models (e.g. gemini, vendor-model, gpt)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;">
|
||||
<button id="btn-discover-models" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search API</button>
|
||||
</div>
|
||||
<div id="admin-discovered-models" style="margin-top:10px;display:flex;flex-direction:column;gap:4px;max-height:400px;overflow-y:auto;">
|
||||
</div>
|
||||
<p id="admin-discover-hint" style="font-size:12px;color:var(--g500);margin:8px 0 0;">Click "Search API" to query your configured provider for all available models. Use the search box to filter results.</p>
|
||||
</div>
|
||||
|
||||
<!-- Custom Model (manual) -->
|
||||
<div style="border-top:1px solid var(--g100);padding-top:14px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Add Custom Model (manual)</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||
<input type="text" id="admin-custom-model-id" placeholder="Model ID (e.g. openai/gpt-4.1)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<input type="text" id="admin-custom-model-name" placeholder="Display name" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<input type="text" id="admin-custom-model-cost" placeholder="Cost (e.g. ~$0.01)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<select id="admin-custom-model-cat" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="fast">Fast & Cheap</option>
|
||||
<option value="smart" selected>Smart</option>
|
||||
<option value="premium">Premium</option>
|
||||
<option value="free">Free</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<button id="btn-add-custom-model" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Model</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Models List -->
|
||||
<div id="admin-custom-models-list" style="display:flex;flex-direction:column;gap:4px;">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TTS Model Management ───────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-volume-up"></i> Text-to-Speech (TTS)</h3>
|
||||
<span id="admin-tts-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<div id="admin-tts-info" style="font-size:12px;color:var(--g500);">Loading...</div>
|
||||
|
||||
<!-- Search voices from provider -->
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Search Voices / Models from Provider</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-tts-search" placeholder="Filter voices (e.g. Journey, Neural, alloy)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||
<button id="btn-discover-tts" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search</button>
|
||||
</div>
|
||||
<div id="admin-tts-discovered" style="margin-top:8px;display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;"></div>
|
||||
<p id="admin-tts-discover-hint" style="font-size:12px;color:var(--g500);margin:6px 0 0;">Click Search to query available voices from your configured TTS provider.</p>
|
||||
</div>
|
||||
|
||||
<!-- Test TTS -->
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Test Synthesis</label>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:80px;">Voice:</label>
|
||||
<select id="admin-tts-voice" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:280px;"></select>
|
||||
</div>
|
||||
<textarea id="admin-tts-test-text" rows="2" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Text to synthesize...">Hello, this is a text to speech test for the Pediatric AI Scribe.</textarea>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btn-test-tts" class="btn-sm btn-primary"><i class="fas fa-play"></i> Synthesize & Play</button>
|
||||
<audio id="admin-tts-audio" controls style="display:none;height:32px;flex:1;min-width:180px;"></audio>
|
||||
</div>
|
||||
<p id="admin-tts-result" style="font-size:12px;color:var(--g500);margin:6px 0 0;"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── STT Model Management ───────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-microphone"></i> Speech-to-Text (STT)</h3>
|
||||
<span id="admin-stt-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<div id="admin-stt-info" style="font-size:12px;color:var(--g500);">Loading...</div>
|
||||
|
||||
<!-- Search models from provider -->
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Search Models from Provider</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-stt-search" placeholder="Filter models (e.g. gemini, whisper)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||
<button id="btn-discover-stt" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search</button>
|
||||
</div>
|
||||
<div id="admin-stt-discovered" style="margin-top:8px;display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;"></div>
|
||||
<p id="admin-stt-discover-hint" style="font-size:12px;color:var(--g500);margin:6px 0 0;">Click Search to query available STT models from your configured provider.</p>
|
||||
</div>
|
||||
|
||||
<!-- Test STT -->
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Test STT — Record & Transcribe</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btn-stt-record" class="btn-sm btn-primary"><i class="fas fa-microphone"></i> Start Recording</button>
|
||||
<span id="admin-stt-recording-status" style="font-size:12px;color:var(--g500);"></span>
|
||||
</div>
|
||||
<div id="admin-stt-result" style="margin-top:10px;padding:10px;background:var(--g50);border-radius:6px;font-size:13px;color:var(--g700);display:none;">
|
||||
<strong>Transcription:</strong> <span id="admin-stt-text"></span>
|
||||
<div id="admin-stt-meta" style="font-size:11px;color:var(--g400);margin-top:4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Embedding Models ───────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-network-wired"></i> Embedding Models</h3>
|
||||
<span id="admin-embed-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<div id="admin-embed-info" style="font-size:12px;color:var(--g500);">Loading...</div>
|
||||
|
||||
<!-- Search embedding models from provider -->
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Search Models from Provider</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-embed-search" placeholder="Filter models (e.g. embedding, vertex)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||
<button id="btn-discover-embeddings" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search</button>
|
||||
</div>
|
||||
<div id="admin-embed-discovered" style="margin-top:8px;display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;"></div>
|
||||
<p id="admin-embed-discover-hint" style="font-size:12px;color:var(--g500);margin:6px 0 0;">Click Search to query available embedding models from your configured provider.</p>
|
||||
</div>
|
||||
|
||||
<!-- Test Embedding -->
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Test Embedding</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-embed-test-text" value="Pediatric patient with fever and cough" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;" placeholder="Sample text to embed...">
|
||||
<button id="btn-test-embedding" class="btn-sm btn-primary"><i class="fas fa-code-branch"></i> Generate</button>
|
||||
</div>
|
||||
<div id="admin-embed-result" style="margin-top:8px;font-size:12px;color:var(--g500);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
|
||||
<div class="card" style="border:1px solid var(--red-light);">
|
||||
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:10px;">
|
||||
<p style="font-size:13px;color:var(--g600);margin:0;">Reset all announcement, feature flag, and email settings back to factory defaults. SMTP configuration and custom models are preserved.</p>
|
||||
<div>
|
||||
<button id="btn-reset-all-defaults" class="btn-sm" style="background:var(--red);color:white;border:none;border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer;"><i class="fas fa-rotate-left"></i> Reset All to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1,523 +0,0 @@
|
|||
<!-- Bedside — emergency and rapid-reference clinical tools, extracted to its own top-level tab. -->
|
||||
<!-- ═══════════ GLOBAL AGE → WEIGHT (available for every calculator below) ═══════════ -->
|
||||
<div id="bedside-shared" style="background:var(--g50);border:1px solid var(--g200);border-radius:10px;padding:12px 14px;margin-bottom:20px;">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--g600);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;"><i class="fas fa-weight-scale" style="color:var(--blue);"></i> Age → Weight estimator</div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;">
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:190px;">
|
||||
<label style="font-weight:600;">Age <span style="font-weight:400;color:var(--g500);font-size:11px;">(e.g. 3y, 6 mo, 15d)</span></label>
|
||||
<input type="text" id="bedside-age" placeholder="e.g. 3y or 6 mo" autocomplete="off">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:150px;max-width:200px;">
|
||||
<label style="font-weight:600;">Formula</label>
|
||||
<select id="bedside-formula">
|
||||
<option value="apls" selected>APLS (Luscombe 2007)</option>
|
||||
<option value="bestguess">Best Guess (Tinning 2007)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:190px;">
|
||||
<label style="font-weight:600;">Weight (kg)</label>
|
||||
<input type="number" id="bedside-weight" min="0.3" max="200" step="0.1" placeholder="auto from age, or type">
|
||||
</div>
|
||||
<button id="btn-bedside-clear" class="btn-sm btn-ghost" type="button">Clear</button>
|
||||
</div>
|
||||
<div id="bedside-estimate-note" style="font-size:12px;color:var(--g600);margin-top:8px;min-height:18px;"></div>
|
||||
<div style="font-size:11px;color:var(--g500);margin-top:4px;">Estimator only — copy the weight into whichever calculator you need.</div>
|
||||
|
||||
<!-- Formulas reference (collapsed by default) -->
|
||||
<details style="margin-top:10px;">
|
||||
<summary style="cursor:pointer;font-size:12px;color:var(--blue-dark);font-weight:600;"><i class="fas fa-info-circle"></i> Weight-estimation formulas</summary>
|
||||
<div style="margin-top:8px;padding:10px 12px;background:white;border:1px solid var(--g200);border-radius:6px;font-size:12px;line-height:1.55;">
|
||||
<div style="font-weight:700;color:var(--g700);margin-bottom:2px;">APLS (Luscombe & Owens 2007 / APLS-UK 2016)</div>
|
||||
<div style="padding-left:12px;">
|
||||
• 0-12 months: <strong>(0.5 × age in months) + 4</strong><br>
|
||||
• 1-5 years: <strong>(2 × age in years) + 8</strong><br>
|
||||
• 6-12 years: <strong>(3 × age in years) + 7</strong>
|
||||
</div>
|
||||
<div style="font-weight:700;color:var(--g700);margin:8px 0 2px;">Best Guess (Tinning & Acworth 2007)</div>
|
||||
<div style="padding-left:12px;">
|
||||
• 1-11 months: <strong>(age in months + 9) / 2</strong><br>
|
||||
• 1-4 years: <strong>2 × (age in years + 5)</strong><br>
|
||||
• 5-14 years: <strong>4 × age in years</strong>
|
||||
</div>
|
||||
<div style="margin-top:8px;color:var(--g500);font-style:italic;">Broselow tape (length-based) is most accurate when available.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-briefcase-medical" style="color:var(--red);"></i> Bedside</h3>
|
||||
<span style="font-size:11px;color:var(--g500);">Weight-based pediatric reference. Always verify against institutional protocols.</span>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--g200);">
|
||||
<button class="calc-pill active" data-em="neonatal"><i class="fas fa-baby"></i> Neonatal</button>
|
||||
<button class="calc-pill" data-em="airway"><i class="fas fa-wind"></i> Airway/RSI</button>
|
||||
<button class="calc-pill" data-em="cardiac"><i class="fas fa-heart-pulse"></i> Cardiac Arrest</button>
|
||||
<button class="calc-pill" data-em="respiratory"><i class="fas fa-lungs"></i> Respiratory</button>
|
||||
<button class="calc-pill" data-em="ventilation"><i class="fas fa-fan"></i> O2 & Ventilation</button>
|
||||
<button class="calc-pill" data-em="seizure"><i class="fas fa-brain"></i> Seizures</button>
|
||||
<button class="calc-pill" data-em="sepsis"><i class="fas fa-virus"></i> Sepsis & Fever</button>
|
||||
<button class="calc-pill" data-em="anaphylaxis"><i class="fas fa-syringe"></i> Anaphylaxis</button>
|
||||
<button class="calc-pill" data-em="sedation"><i class="fas fa-bed-pulse"></i> Sedation</button>
|
||||
<button class="calc-pill" data-em="agitation"><i class="fas fa-user-injured"></i> Agitation</button>
|
||||
<button class="calc-pill" data-em="antiemetics"><i class="fas fa-pills"></i> Antiemetics</button>
|
||||
<button class="calc-pill" data-em="antimicrobials"><i class="fas fa-bacterium"></i> Antimicrobials</button>
|
||||
<button class="calc-pill" data-em="burns"><i class="fas fa-fire"></i> Burns</button>
|
||||
<button class="calc-pill" data-em="toxicology"><i class="fas fa-skull-crossbones"></i> Toxicology</button>
|
||||
<button class="calc-pill" data-em="trauma"><i class="fas fa-user-injured" style="transform:rotate(-45deg);"></i> Trauma</button>
|
||||
</div>
|
||||
|
||||
<!-- ── NEONATAL ── -->
|
||||
<div id="em-neonatal" class="em-section">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Neonatal Assessment</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">GA classification, birth weight assessment (AGA/SGA/LGA), and prematurity category. Fenton 2013 / WHO.</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:130px;">
|
||||
<label>GA (weeks)</label>
|
||||
<input type="number" id="neo-ga-weeks" min="22" max="44" step="1" placeholder="e.g. 34">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:130px;">
|
||||
<label>GA (days)</label>
|
||||
<input type="number" id="neo-ga-days" min="0" max="6" step="1" placeholder="0-6">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:130px;">
|
||||
<label>Birth Weight (g)</label>
|
||||
<input type="number" id="neo-weight" min="200" max="6000" step="1" placeholder="e.g. 2500">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:110px;">
|
||||
<label>Sex</label>
|
||||
<select id="neo-sex">
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-neo-assess" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-calculator"></i> Assess</button>
|
||||
<div id="neo-result"></div>
|
||||
|
||||
<details style="margin-top:24px;border-top:1px solid var(--g200);padding-top:16px;">
|
||||
<summary style="cursor:pointer;font-size:14px;font-weight:700;color:var(--g800);"><i class="fas fa-heart-pulse" style="color:var(--red);"></i> NRP Resuscitation Pathway</summary>
|
||||
<p style="font-size:12px;color:var(--g500);margin:8px 0;">AHA Neonatal Resuscitation Program 2020. Static visual pathway — enter weight to calculate drug doses.</p>
|
||||
<div style="margin-bottom:10px;">
|
||||
<button class="btn-sm btn-ghost" type="button" data-img-src="/img/nrp_pathway.png" data-img-title="NRP Pathway (reference)"><i class="fas fa-image"></i> View pathway image</button>
|
||||
</div>
|
||||
<div id="nrp-pathway-static"></div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-top:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:170px;max-width:240px;">
|
||||
<label>Current Weight (kg)</label>
|
||||
<input type="number" id="nrp-weight" min="0.3" max="6" step="0.01" placeholder="e.g. 3.0">
|
||||
</div>
|
||||
<button id="btn-nrp-calc" class="btn-sm btn-primary"><i class="fas fa-calculator"></i> Calculate NRP Doses</button>
|
||||
</div>
|
||||
<div id="nrp-result" style="margin-top:12px;"></div>
|
||||
</details>
|
||||
|
||||
<!-- Apgar Score -->
|
||||
<div style="margin-top:24px;border-top:1px solid var(--g200);padding-top:16px;">
|
||||
<h4 style="font-size:14px;font-weight:700;color:var(--g800);margin:0 0 4px;"><i class="fas fa-clipboard-check" style="color:var(--blue);"></i> Apgar Score</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 10px;">Assess at <strong>1 and 5 minutes</strong>. If <7 at 5 min, repeat every 5 min up to 20 min. Apgar is a description — <em>never</em> delay resuscitation waiting for a score.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
|
||||
<div class="calc-field" style="flex:1;min-width:150px;">
|
||||
<label><strong>A</strong>ppearance (color)</label>
|
||||
<select id="apgar-appearance">
|
||||
<option value="0">0 — Blue / pale all over</option>
|
||||
<option value="1" selected>1 — Body pink, extremities blue</option>
|
||||
<option value="2">2 — All pink</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:150px;">
|
||||
<label><strong>P</strong>ulse (HR)</label>
|
||||
<select id="apgar-pulse">
|
||||
<option value="0">0 — Absent</option>
|
||||
<option value="1">1 — <100</option>
|
||||
<option value="2" selected>2 — ≥100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:150px;">
|
||||
<label><strong>G</strong>rimace (reflex)</label>
|
||||
<select id="apgar-grimace">
|
||||
<option value="0">0 — No response</option>
|
||||
<option value="1">1 — Grimace / weak cry</option>
|
||||
<option value="2" selected>2 — Cry / active withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:150px;">
|
||||
<label><strong>A</strong>ctivity (tone)</label>
|
||||
<select id="apgar-activity">
|
||||
<option value="0">0 — Limp</option>
|
||||
<option value="1">1 — Some flexion</option>
|
||||
<option value="2" selected>2 — Active motion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:150px;">
|
||||
<label><strong>R</strong>espiration</label>
|
||||
<select id="apgar-respiration">
|
||||
<option value="0">0 — Absent</option>
|
||||
<option value="1">1 — Slow / irregular</option>
|
||||
<option value="2" selected>2 — Strong cry</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-apgar-calc" class="btn-sm btn-primary"><i class="fas fa-calculator"></i> Calculate Apgar</button>
|
||||
<div id="apgar-result" style="margin-top:10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── AIRWAY / RSI ── -->
|
||||
<div id="em-airway" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Airway Management / RSI</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">RSI drugs + equipment sizing + initial ventilator settings.</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:200px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="airway-weight" min="1" max="150" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:200px;">
|
||||
<label>Age (years, for equipment sizing)</label>
|
||||
<input type="number" id="airway-age" min="0" max="18" step="0.5" placeholder="e.g. 5">
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-airway-calc" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-calculator"></i> Calculate</button>
|
||||
<div id="airway-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── CARDIAC ARREST / PALS ── -->
|
||||
<div id="em-cardiac" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Cardiac Arrest / PALS</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">PALS algorithm medications for arrest and peri-arrest rhythms.</p>
|
||||
<div class="calc-field" style="max-width:200px;margin-bottom:12px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="cardiac-weight" min="1" max="150" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<button class="btn-sm btn-primary" data-cardiac="general">General Doses</button>
|
||||
<button class="btn-sm btn-primary" data-cardiac="asystole">Asystole / PEA</button>
|
||||
<button class="btn-sm btn-primary" data-cardiac="brady">Bradycardia</button>
|
||||
<button class="btn-sm btn-primary" data-cardiac="svt">SVT</button>
|
||||
<button class="btn-sm btn-primary" data-cardiac="vfib">VF / Pulseless VT</button>
|
||||
<button class="btn-sm btn-primary" data-cardiac="vt">Stable VT</button>
|
||||
</div>
|
||||
<div id="cardiac-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── RESPIRATORY ── -->
|
||||
<div id="em-respiratory" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Respiratory Management</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Asthma, croup, bronchiolitis — scores, stepwise management, weight-based drug dosing.</p>
|
||||
<div class="calc-field" style="max-width:200px;margin-bottom:12px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="resp-weight" min="1" max="150" step="0.1" placeholder="e.g. 15">
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;">
|
||||
<button class="calc-pill active" data-resp="asthma">Asthma</button>
|
||||
<button class="calc-pill" data-resp="croup">Croup</button>
|
||||
<button class="calc-pill" data-resp="bronchiolitis">Bronchiolitis</button>
|
||||
</div>
|
||||
|
||||
<div id="resp-asthma">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 10px;">Select severity to see stepwise management with weight-based dosing.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
|
||||
<button class="btn-sm" data-asthma-severity="mild" style="background:var(--green);color:white;">Mild</button>
|
||||
<button class="btn-sm" data-asthma-severity="moderate" style="background:var(--amber);color:white;">Moderate</button>
|
||||
<button class="btn-sm" data-asthma-severity="severe" style="background:var(--red);color:white;">Severe</button>
|
||||
</div>
|
||||
<div id="asthma-result"></div>
|
||||
<div style="margin-top:16px;border-top:1px solid var(--g200);padding-top:12px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:8px;display:block;">PRAM Score (Pediatric Respiratory Assessment Measure)</label>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>O2 Saturation</label>
|
||||
<select id="pram-spo2"><option value="0">≥95%</option><option value="1">92-94%</option><option value="2"><92%</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Suprasternal Retractions</label>
|
||||
<select id="pram-retractions"><option value="0">Absent</option><option value="2">Present</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Scalene Contraction</label>
|
||||
<select id="pram-scalene"><option value="0">Absent</option><option value="2">Present</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Air Entry</label>
|
||||
<select id="pram-air"><option value="0">Normal</option><option value="1">Decreased at bases</option><option value="2">Widespread decrease</option><option value="3">Absent/minimal</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Wheezing</label>
|
||||
<select id="pram-wheeze"><option value="0">Absent</option><option value="1">Expiratory only</option><option value="2">Insp & exp</option><option value="3">Audible / silent chest</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-pram-calc" class="btn-sm btn-primary"><i class="fas fa-calculator"></i> Calculate PRAM</button>
|
||||
<div id="pram-result" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resp-croup" style="display:none;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 10px;">Westley Croup Score → severity-based treatment.</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Consciousness</label>
|
||||
<select id="croup-conscious"><option value="0">Normal</option><option value="5">Disoriented</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Cyanosis</label>
|
||||
<select id="croup-cyanosis"><option value="0">None</option><option value="4">With agitation</option><option value="5">At rest</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Stridor</label>
|
||||
<select id="croup-stridor"><option value="0">None</option><option value="1">With agitation</option><option value="2">At rest</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Air Entry</label>
|
||||
<select id="croup-air"><option value="0">Normal</option><option value="1">Decreased</option><option value="2">Markedly decreased</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Retractions</label>
|
||||
<select id="croup-retractions"><option value="0">None</option><option value="1">Mild</option><option value="2">Moderate</option><option value="3">Severe</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-croup-calc" class="btn-sm btn-primary"><i class="fas fa-calculator"></i> Calculate Westley Score</button>
|
||||
<div id="croup-result" style="margin-top:10px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="resp-bronchiolitis" style="display:none;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 10px;">Bronchiolitis management per AAP 2014/2023 — primarily supportive.</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Age</label>
|
||||
<select id="bronch-age"><option value="<12w"><12 weeks</option><option value="12w-12m">12 wks - 12 mo</option><option value=">12m">>12 months</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>O2 Saturation</label>
|
||||
<select id="bronch-spo2"><option value="normal">≥90%</option><option value="low"><90%</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Hydration</label>
|
||||
<select id="bronch-hydration"><option value="adequate">Adequate</option><option value="poor">Poor / dehydrated</option></select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Resp Distress</label>
|
||||
<select id="bronch-distress"><option value="mild">Mild</option><option value="moderate">Moderate</option><option value="severe">Severe / apnea</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-bronch-calc" class="btn-sm btn-primary"><i class="fas fa-calculator"></i> Assess</button>
|
||||
<div id="bronch-result" style="margin-top:10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── O2 & VENTILATION (teaching + reference) ── -->
|
||||
<div id="em-ventilation" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 14px;">Oxygen & Ventilation</h4>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:200px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="vent-weight" min="1" max="150" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:200px;">
|
||||
<label>Age (years, optional)</label>
|
||||
<input type="number" id="vent-age" min="0" max="18" step="0.5" placeholder="e.g. 5">
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-vent-show" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-diagram-project"></i> Show Reference</button>
|
||||
<div id="vent-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── SEIZURES / STATUS EPILEPTICUS ── -->
|
||||
<div id="em-seizure" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Status Epilepticus</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Time-based pathway — stabilize → 1st benzo → 2nd benzo → 2nd-line AED → refractory.</p>
|
||||
<div class="calc-field" style="max-width:200px;margin-bottom:12px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="seizure-weight" min="1" max="150" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<button id="btn-seizure-calc" class="btn-sm btn-primary"><i class="fas fa-diagram-project"></i> Show Pathway</button>
|
||||
<button class="btn-sm btn-ghost" data-img-src="/img/epilepsy_eiic_pathway.png" data-img-title="Pediatric Status Epilepticus Pathway (reference)"><i class="fas fa-image"></i> View pathway image</button>
|
||||
</div>
|
||||
<div id="seizure-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── SEPSIS ── -->
|
||||
<div id="em-sepsis" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Sepsis & Fever Workup</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Phoenix Sepsis Criteria (2024), age-based approach, first-hour bundle, and validated febrile-infant rules (PECARN / Aronson / Rochester / Step-by-Step).</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:200px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="sepsis-weight" min="1" max="150" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:180px;max-width:240px;">
|
||||
<label>Age group</label>
|
||||
<select id="sepsis-age">
|
||||
<option value="neonate">Neonate (0-28 days)</option>
|
||||
<option value="infant">Young infant (29 d - 3 mo)</option>
|
||||
<option value="child" selected>Older child / adolescent (>3 mo)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-sepsis-show" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-list-check"></i> Show Approach</button>
|
||||
<div id="sepsis-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── ANAPHYLAXIS ── -->
|
||||
<div id="em-anaphylaxis" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Anaphylaxis</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Stepwise management with weight-based dosing.</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:200px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="anaph-weight" min="1" max="150" step="0.1" placeholder="e.g. 25">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:200px;">
|
||||
<label>Age Group</label>
|
||||
<select id="anaph-age"><option value="infant"><1 year</option><option value="child" selected>1-12 years</option><option value="adolescent">>12 years</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-anaph-calc" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-calculator"></i> Calculate Doses</button>
|
||||
<div id="anaph-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── PROCEDURAL SEDATION ── -->
|
||||
<div id="em-sedation" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Procedural Sedation</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Sedation agents + reversal drugs.</p>
|
||||
<div class="calc-field" style="max-width:200px;margin-bottom:12px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="sed-weight" min="1" max="150" step="0.1" placeholder="e.g. 15">
|
||||
</div>
|
||||
<button id="btn-sed-calc" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-calculator"></i> Calculate Doses</button>
|
||||
<div id="sed-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── AGITATION ── -->
|
||||
<div id="em-agitation" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Acute Agitation</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Stepwise: verbal de-escalation → oral → IM/IV.</p>
|
||||
<div class="calc-field" style="max-width:200px;margin-bottom:12px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="agit-weight" min="1" max="150" step="0.1" placeholder="e.g. 30">
|
||||
</div>
|
||||
<button id="btn-agit-calc" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-calculator"></i> Calculate Doses</button>
|
||||
<div id="agit-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── ANTIEMETICS ── -->
|
||||
<div id="em-antiemetics" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Antiemetics</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Common pediatric antiemetic dosing with max doses and key cautions.</p>
|
||||
<div class="calc-field" style="max-width:200px;margin-bottom:12px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="emet-weight" min="1" max="150" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<button id="btn-emet-calc" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-calculator"></i> Calculate Doses</button>
|
||||
<div id="emet-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── ANTIMICROBIALS ── -->
|
||||
<div id="em-antimicrobials" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Empiric Antimicrobials</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Empiric regimens by age and infection type. Always tailor to local resistance, cultures, and severity.</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:160px;">
|
||||
<label>Age</label>
|
||||
<select id="abx-age">
|
||||
<option value="neonate">Neonate (0-28 days)</option>
|
||||
<option value="infant">Infant (1-3 months)</option>
|
||||
<option value="child">Child (>3 months)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:200px;">
|
||||
<label>Infection</label>
|
||||
<select id="abx-infection">
|
||||
<option value="sepsis">Sepsis / bacteremia</option>
|
||||
<option value="meningitis">Meningitis</option>
|
||||
<option value="pna">Community-acquired pneumonia</option>
|
||||
<option value="uti">UTI / pyelonephritis</option>
|
||||
<option value="skin">Skin / soft tissue</option>
|
||||
<option value="ent">AOM / sinusitis / pharyngitis</option>
|
||||
<option value="neutropenic">Febrile neutropenia</option>
|
||||
<option value="ic">Intra-abdominal</option>
|
||||
<option value="bone">Osteomyelitis / septic arthritis</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-abx-lookup" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-search"></i> Look Up Regimen</button>
|
||||
<div id="abx-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── BURNS ── -->
|
||||
<div id="em-burns" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Burn Management</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">TBSA body-parts calculator (Lund-Browder age-adjusted) + Parkland fluid resuscitation. Count only 2nd-degree or deeper.</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:130px;max-width:180px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="burn-weight" min="1" max="150" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:130px;max-width:180px;">
|
||||
<label>Age group</label>
|
||||
<select id="burn-age">
|
||||
<option value="infant">Infant (<1 yr)</option>
|
||||
<option value="young" selected>1-5 years</option>
|
||||
<option value="child">5-10 years</option>
|
||||
<option value="adol">10-15 years</option>
|
||||
<option value="adult">>15 / adult</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:130px;max-width:180px;">
|
||||
<label>Override TBSA %</label>
|
||||
<input type="number" id="burn-tbsa" min="0" max="100" step="0.5" placeholder="auto from body parts">
|
||||
</div>
|
||||
</div>
|
||||
<div id="burn-bodyparts-wrapper" style="margin-bottom:12px;"></div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:12px;">
|
||||
<button id="btn-burn-calc" class="btn-sm btn-primary" type="button"><i class="fas fa-calculator"></i> Calculate Parkland</button>
|
||||
<button id="btn-burn-reset" class="btn-sm btn-ghost" type="button">Reset body parts</button>
|
||||
<span id="burn-tbsa-live" style="font-size:13px;font-weight:700;color:var(--blue-dark);"></span>
|
||||
</div>
|
||||
<div id="burn-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── TOXICOLOGY ── -->
|
||||
<div id="em-toxicology" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Toxicology</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Approach, decontamination, common ingestions with antidotes, dialyzable drugs reference.</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<div class="calc-field" style="flex:1;min-width:140px;max-width:200px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="tox-weight" min="1" max="150" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<div class="calc-field" style="flex:1;min-width:200px;">
|
||||
<label>Topic</label>
|
||||
<select id="tox-topic">
|
||||
<option value="approach">General approach + decontamination</option>
|
||||
<option value="acetaminophen">Acetaminophen</option>
|
||||
<option value="opioids">Opioids</option>
|
||||
<option value="iron">Iron</option>
|
||||
<option value="tca">TCAs</option>
|
||||
<option value="bbccb">β-blocker / CCB</option>
|
||||
<option value="benzo">Benzodiazepines</option>
|
||||
<option value="organo">Organophosphates / carbamates</option>
|
||||
<option value="salicylate">Salicylates</option>
|
||||
<option value="etoh">Toxic alcohols</option>
|
||||
<option value="dialyzable">Dialyzable drugs (ISTUMBLE)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-tox-lookup" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-search"></i> Look Up</button>
|
||||
<div id="tox-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── TRAUMA ── -->
|
||||
<div id="em-trauma" class="em-section" style="display:none;">
|
||||
<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 4px;">Trauma</h4>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Primary / secondary survey, c-spine, massive transfusion, TXA.</p>
|
||||
<div class="calc-field" style="max-width:200px;margin-bottom:12px;">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="trauma-weight" min="1" max="150" step="0.1" placeholder="e.g. 30">
|
||||
</div>
|
||||
<button id="btn-trauma-show" class="btn-sm btn-primary" style="margin-bottom:12px;"><i class="fas fa-list"></i> Show Reference</button>
|
||||
<div id="trauma-result"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,658 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-calculator"></i> Pediatric Calculators</h2>
|
||||
<p>Clinical calculators for pediatric medicine</p>
|
||||
</div>
|
||||
|
||||
<!-- Calculator selector pills -->
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px;">
|
||||
<button class="calc-nav-pill active" data-calc="bp">BP Percentile</button>
|
||||
<button class="calc-nav-pill" data-calc="bmi">BMI Percentile</button>
|
||||
<button class="calc-nav-pill" data-calc="growth">Growth Charts</button>
|
||||
<button class="calc-nav-pill" data-calc="bili">Bilirubin</button>
|
||||
<button class="calc-nav-pill" data-calc="vitals">Vital Signs</button>
|
||||
<button class="calc-nav-pill" data-calc="bsa">Body Surface Area</button>
|
||||
<button class="calc-nav-pill" data-calc="dose">Weight-Based Dosing</button>
|
||||
<button class="calc-nav-pill" data-calc="resus">Resus Meds</button>
|
||||
<button class="calc-nav-pill" data-calc="gcs">GCS</button>
|
||||
<button class="calc-nav-pill" data-calc="equipment">Equipment</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════ BP PERCENTILE CALCULATOR ═══════════ -->
|
||||
<div id="calc-bp" class="calc-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-heart-pulse"></i> Blood Pressure Percentile</h3>
|
||||
<span style="font-size:11px;color:var(--g500);">AAP 2017 Clinical Practice Guideline</span>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 16px;">Based on normative tables for normal-weight children ages 1-17. Flynn et al., Pediatrics 2017;140(3):e20171904.</p>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Age (years)</label>
|
||||
<input type="number" id="bp-age" min="1" max="17" step="1" placeholder="1-17">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Sex</label>
|
||||
<select id="bp-sex"><option value="">Select</option><option value="male">Male</option><option value="female">Female</option></select>
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Height (cm)</label>
|
||||
<input type="number" id="bp-height" min="50" max="200" step="0.1" placeholder="e.g. 110">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Systolic BP (mmHg)</label>
|
||||
<input type="number" id="bp-systolic" min="50" max="250" placeholder="e.g. 105">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Diastolic BP (mmHg)</label>
|
||||
<input type="number" id="bp-diastolic" min="30" max="150" placeholder="e.g. 65">
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-calc-bp" class="btn-sm btn-primary" style="margin-top:12px;"><i class="fas fa-calculator"></i> Calculate</button>
|
||||
<button id="btn-clear-bp" class="btn-sm btn-ghost" style="margin-top:12px;">Clear</button>
|
||||
<div id="bp-result" class="calc-result hidden"></div>
|
||||
<div id="bp-chart-container" class="chart-container hidden"><canvas id="bp-chart-canvas"></canvas></div>
|
||||
|
||||
<details style="margin-top:16px;font-size:12px;border:1px solid var(--g200);border-radius:8px;padding:0;">
|
||||
<summary style="padding:10px 14px;cursor:pointer;font-weight:600;color:var(--g700);background:var(--g50);border-radius:8px;">Definitions: Hypertension & Hypotension (AAP 2017 / Nelson)</summary>
|
||||
<div style="padding:12px 14px;">
|
||||
<p style="font-weight:700;color:var(--g800);margin:0 0 8px;">Hypertension Classification (AAP 2017)</p>
|
||||
<table style="width:100%;font-size:12px;border-collapse:collapse;margin-bottom:14px;">
|
||||
<thead><tr style="background:var(--g100);"><th style="padding:6px 8px;text-align:left;border:1px solid var(--g200);">Category</th><th style="padding:6px 8px;text-align:left;border:1px solid var(--g200);">Ages 1 to <13 years</th><th style="padding:6px 8px;text-align:left;border:1px solid var(--g200);">Ages ≥13 years</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="padding:6px 8px;border:1px solid var(--g200);font-weight:600;color:#10b981;">Normal</td><td style="padding:6px 8px;border:1px solid var(--g200);"><90th percentile</td><td style="padding:6px 8px;border:1px solid var(--g200);"><120/<80 mmHg</td></tr>
|
||||
<tr><td style="padding:6px 8px;border:1px solid var(--g200);font-weight:600;color:#f59e0b;">Elevated BP</td><td style="padding:6px 8px;border:1px solid var(--g200);">≥90th to <95th percentile<br>OR 120/80 mmHg to <95th (whichever lower)</td><td style="padding:6px 8px;border:1px solid var(--g200);">120-129/<80 mmHg</td></tr>
|
||||
<tr><td style="padding:6px 8px;border:1px solid var(--g200);font-weight:600;color:#f97316;">Stage 1 HTN</td><td style="padding:6px 8px;border:1px solid var(--g200);">≥95th percentile to <95th + 12 mmHg<br>OR 130/80-139/89 mmHg (whichever lower)</td><td style="padding:6px 8px;border:1px solid var(--g200);">130/80 to 139/89 mmHg</td></tr>
|
||||
<tr><td style="padding:6px 8px;border:1px solid var(--g200);font-weight:600;color:#ef4444;">Stage 2 HTN</td><td style="padding:6px 8px;border:1px solid var(--g200);">≥95th + 12 mmHg<br>OR ≥140/90 mmHg (whichever lower)</td><td style="padding:6px 8px;border:1px solid var(--g200);">≥140/90 mmHg</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-weight:700;color:var(--g800);margin:0 0 6px;">Hypertensive Urgency vs Emergency (Nelson)</p>
|
||||
<ul style="margin:0 0 14px;padding-left:18px;color:var(--g700);line-height:1.7;">
|
||||
<li><strong>Hypertensive urgency:</strong> Severe BP elevation (>95th + 30 mmHg or >180/120) WITHOUT end-organ damage. Requires reduction over 24-48 hours.</li>
|
||||
<li><strong>Hypertensive emergency:</strong> Severe BP elevation WITH end-organ damage (encephalopathy, heart failure, retinopathy, renal injury). Requires immediate IV therapy with goal reduction of 25% in first 8 hours.</li>
|
||||
</ul>
|
||||
<p style="font-weight:700;color:var(--g800);margin:0 0 6px;">Hypotension (AAP/PALS)</p>
|
||||
<ul style="margin:0 0 8px;padding-left:18px;color:var(--g700);line-height:1.7;">
|
||||
<li><strong>Definition:</strong> Systolic BP below the 5th percentile for age, sex, and height.</li>
|
||||
<li><strong>Quick estimate (1-10 years):</strong> Hypotension = SBP < 70 + (2 × age in years) mmHg</li>
|
||||
<li><strong>Neonates:</strong> SBP < gestational age in weeks (term: <60 mmHg)</li>
|
||||
<li><strong>Clinical significance:</strong> Hypotension in children is a late sign of shock. Tachycardia, poor perfusion, and altered mental status typically precede hypotension.</li>
|
||||
</ul>
|
||||
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">Sources: Flynn JT et al., Pediatrics 2017;140(3). Nelson Textbook of Pediatrics, 21st ed. PALS Provider Manual.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ BMI PERCENTILE CALCULATOR ═══════════ -->
|
||||
<div id="calc-bmi" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-weight-scale"></i> BMI Percentile</h3>
|
||||
<span style="font-size:11px;color:var(--g500);">CDC Growth Charts, ages 2-20</span>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 16px;">Based on CDC 2000 growth reference data. For children ages 2-20 years.</p>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Age (years)</label>
|
||||
<input type="number" id="bmi-age-yr" min="2" max="20" step="1" placeholder="e.g. 7" style="width:60%;display:inline-block;">
|
||||
<select id="bmi-age-mo" style="width:35%;display:inline-block;padding:8px 4px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;">
|
||||
<option value="0">0 mo</option><option value="1">1 mo</option><option value="2">2 mo</option><option value="3">3 mo</option>
|
||||
<option value="4">4 mo</option><option value="5">5 mo</option><option value="6">6 mo</option><option value="7">7 mo</option>
|
||||
<option value="8">8 mo</option><option value="9">9 mo</option><option value="10">10 mo</option><option value="11">11 mo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Sex</label>
|
||||
<select id="bmi-sex"><option value="">Select</option><option value="male">Male</option><option value="female">Female</option></select>
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="bmi-weight" min="5" max="200" step="0.1" placeholder="e.g. 25">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Height (cm)</label>
|
||||
<input type="number" id="bmi-height" min="50" max="200" step="0.1" placeholder="e.g. 120">
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-calc-bmi" class="btn-sm btn-primary" style="margin-top:12px;"><i class="fas fa-calculator"></i> Calculate</button>
|
||||
<button id="btn-clear-bmi" class="btn-sm btn-ghost" style="margin-top:12px;">Clear</button>
|
||||
<div id="bmi-result" class="calc-result hidden"></div>
|
||||
<div id="bmi-chart-container" class="chart-container hidden"><canvas id="bmi-chart-canvas"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ BODY SURFACE AREA ═══════════ -->
|
||||
<div id="calc-bsa" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-child"></i> Body Surface Area</h3>
|
||||
<span style="font-size:11px;color:var(--g500);">Mosteller formula</span>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 16px;">Mosteller formula: BSA (m2) = sqrt(height(cm) x weight(kg) / 3600)</p>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="bsa-weight" min="1" max="200" step="0.1" placeholder="e.g. 20">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Height (cm)</label>
|
||||
<input type="number" id="bsa-height" min="30" max="220" step="0.1" placeholder="e.g. 110">
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-calc-bsa" class="btn-sm btn-primary" style="margin-top:12px;"><i class="fas fa-calculator"></i> Calculate</button>
|
||||
<button id="btn-clear-bsa" class="btn-sm btn-ghost" style="margin-top:12px;">Clear</button>
|
||||
<div id="bsa-result" class="calc-result hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ WEIGHT-BASED DOSING ═══════════ -->
|
||||
<div id="calc-dose" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-pills"></i> Weight-Based Dosing</h3>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 16px;">Calculate dose based on weight. Always verify against formulary and institutional guidelines.</p>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Patient Weight (kg)</label>
|
||||
<input type="number" id="dose-weight" min="1" max="200" step="0.1" placeholder="e.g. 15">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Dose (mg/kg)</label>
|
||||
<input type="number" id="dose-per-kg" min="0.01" step="0.01" placeholder="e.g. 10">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Frequency</label>
|
||||
<select id="dose-freq">
|
||||
<option value="1">Once daily</option>
|
||||
<option value="2">Twice daily (BID)</option>
|
||||
<option value="3">Three times daily (TID)</option>
|
||||
<option value="4">Four times daily (QID)</option>
|
||||
<option value="6">Every 4 hours (Q4H)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Max single dose (mg, optional)</label>
|
||||
<input type="number" id="dose-max" min="0" step="1" placeholder="e.g. 500">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Concentration (mg/mL, optional)</label>
|
||||
<input type="number" id="dose-conc" min="0" step="0.1" placeholder="e.g. 40">
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-calc-dose" class="btn-sm btn-primary" style="margin-top:12px;"><i class="fas fa-calculator"></i> Calculate</button>
|
||||
<button id="btn-clear-dose" class="btn-sm btn-ghost" style="margin-top:12px;">Clear</button>
|
||||
<div id="dose-result" class="calc-result hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ GROWTH CHARTS ═══════════ -->
|
||||
<div id="calc-growth" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-chart-line"></i> Growth Percentiles</h3>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
|
||||
<button class="calc-pill active" data-growth="wfa">Weight-for-Age</button>
|
||||
<button class="calc-pill" data-growth="lfa">Length/Height-for-Age</button>
|
||||
<button class="calc-pill" data-growth="hcfa">Head Circumference</button>
|
||||
<button class="calc-pill" data-growth="wfl">Weight-for-Length</button>
|
||||
<button class="calc-pill" data-growth="fenton">Fenton (Preterm)</button>
|
||||
</div>
|
||||
<p id="growth-source-text" style="font-size:12px;color:var(--g500);margin:0 0 14px;">WHO growth standards (0-2y) / CDC 2000 (2-20y).</p>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Sex</label>
|
||||
<select id="growth-sex"><option value="">Select</option><option value="male">Male</option><option value="female">Female</option></select>
|
||||
</div>
|
||||
<div class="calc-field" id="growth-age-field">
|
||||
<label>Age <span style="font-weight:400;color:var(--g500);font-size:11px;">(fill any combination)</span></label>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<input type="number" id="growth-age-yr" min="0" max="20" step="1" placeholder="yr" style="flex:1;min-width:0;">
|
||||
<input type="number" id="growth-age-mo" min="0" max="240" step="1" placeholder="mo" style="flex:1;min-width:0;">
|
||||
<input type="number" id="growth-age-d" min="0" max="365" step="1" placeholder="d" style="flex:1;min-width:0;">
|
||||
</div>
|
||||
<div id="growth-age-parsed" style="font-size:11px;color:var(--g500);margin-top:4px;min-height:14px;"></div>
|
||||
</div>
|
||||
<div class="calc-field" id="growth-ga-field" style="display:none;">
|
||||
<label>Gestational Age (weeks)</label>
|
||||
<input type="number" id="growth-ga" min="22" max="50" step="1" placeholder="22-50">
|
||||
</div>
|
||||
<div class="calc-field" id="growth-weight-field">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="growth-weight" min="0.3" max="200" step="0.01" placeholder="e.g. 10.5">
|
||||
</div>
|
||||
<div class="calc-field" id="growth-length-field">
|
||||
<label>Length/Height (cm)</label>
|
||||
<input type="number" id="growth-length" min="20" max="200" step="0.1" placeholder="e.g. 75">
|
||||
</div>
|
||||
<div class="calc-field" id="growth-hc-field" style="display:none;">
|
||||
<label>Head Circumference (cm)</label>
|
||||
<input type="number" id="growth-hc" min="20" max="60" step="0.1" placeholder="e.g. 35">
|
||||
</div>
|
||||
</div>
|
||||
<div id="growth-mph-section" style="display:none;margin-top:12px;padding:12px;background:var(--g50);border-radius:8px;">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:8px;">Mid-Parental Height (optional)</div>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Mother's Height (cm)</label>
|
||||
<input type="number" id="growth-mother-ht" min="120" max="200" step="0.1" placeholder="e.g. 165">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Father's Height (cm)</label>
|
||||
<input type="number" id="growth-father-ht" min="140" max="220" step="0.1" placeholder="e.g. 178">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-calc-growth" class="btn-sm btn-primary" style="margin-top:12px;"><i class="fas fa-calculator"></i> Calculate</button>
|
||||
<button id="btn-clear-growth" class="btn-sm btn-ghost" style="margin-top:12px;">Clear</button>
|
||||
<div id="growth-result" class="calc-result hidden"></div>
|
||||
<div id="growth-chart-container" class="chart-container hidden"><canvas id="growth-chart-canvas"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ BILIRUBIN NOMOGRAM ═══════════ -->
|
||||
<div id="calc-bili" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-droplet"></i> Neonatal Bilirubin</h3>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
|
||||
<button class="calc-pill active" data-bili="aap">AAP 2022 Phototherapy</button>
|
||||
<button class="calc-pill" data-bili="bhutani">Bhutani Nomogram</button>
|
||||
</div>
|
||||
|
||||
<div id="bili-aap-panel">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">AAP 2022 Clinical Practice Guideline. Determines phototherapy threshold based on gestational age and risk factors.</p>
|
||||
<details style="margin-bottom:14px;font-size:12px;border:1px solid var(--g200);border-radius:8px;padding:0;">
|
||||
<summary style="padding:10px 14px;cursor:pointer;font-weight:600;color:var(--g700);background:var(--g50);border-radius:8px;">Neurotoxicity Risk Factors for Phototherapy Threshold (AAP 2022)</summary>
|
||||
<div style="padding:12px 14px;">
|
||||
<p style="font-weight:600;color:#ef4444;margin:0 0 6px;">Higher Risk (use lower threshold):</p>
|
||||
<ul style="margin:0 0 10px;padding-left:18px;color:var(--g700);line-height:1.7;">
|
||||
<li>Gestational age 35-37 weeks (with other risk factors)</li>
|
||||
<li>Albumin < 3.0 g/dL</li>
|
||||
<li>Isoimmune hemolytic disease (ABO, Rh incompatibility)</li>
|
||||
<li>G6PD deficiency</li>
|
||||
<li>Asphyxia / significant lethargy</li>
|
||||
<li>Sepsis</li>
|
||||
<li>Acidosis (pH < 7.15)</li>
|
||||
<li>Temperature instability / hypothermia</li>
|
||||
</ul>
|
||||
<p style="font-weight:600;color:#10b981;margin:0 0 6px;">Lower Risk (use higher threshold):</p>
|
||||
<ul style="margin:0;padding-left:18px;color:var(--g700);line-height:1.7;">
|
||||
<li>Gestational age ≥ 38 weeks</li>
|
||||
<li>No hemolysis</li>
|
||||
<li>Well-appearing infant</li>
|
||||
<li>No risk factors above</li>
|
||||
</ul>
|
||||
<p style="margin:8px 0 0;font-size:11px;color:var(--g400);">Source: AAP 2022 Clinical Practice Guideline</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details style="margin-bottom:14px;font-size:12px;border:1px solid var(--g200);border-radius:8px;padding:0;">
|
||||
<summary style="padding:10px 14px;cursor:pointer;font-weight:600;color:var(--g700);background:var(--g50);border-radius:8px;">Risk Factors for Severe Neonatal Hyperbilirubinemia (Nelson Table 137.1)</summary>
|
||||
<div style="padding:12px 14px;">
|
||||
<p style="font-weight:600;color:var(--g800);margin:0 0 6px;">Genetic Factors</p>
|
||||
<ul style="margin:0 0 12px;padding-left:18px;color:var(--g700);line-height:1.6;">
|
||||
<li>Gilbert syndrome</li>
|
||||
<li>Crigler-Najjar syndrome</li>
|
||||
<li>Alagille syndrome</li>
|
||||
<li>β-thalassemia</li>
|
||||
<li>Glucose-6-phosphate dehydrogenase (G6PD) deficiency</li>
|
||||
<li>Bilirubin glucuronosyltransferase polymorphism</li>
|
||||
<li>Pyruvate kinase deficiency</li>
|
||||
<li>Erythrocyte structural defects (hereditary spherocytosis, elliptocytosis)</li>
|
||||
<li>Galactosemia</li>
|
||||
</ul>
|
||||
<p style="font-weight:600;color:var(--g800);margin:0 0 6px;">Maternal Factors</p>
|
||||
<ul style="margin:0 0 12px;padding-left:18px;color:var(--g700);line-height:1.6;">
|
||||
<li>Family history of severe jaundice, splenectomy, or cholecystectomy</li>
|
||||
<li>Primiparity</li>
|
||||
<li>Teenage pregnancy</li>
|
||||
<li>Diabetes</li>
|
||||
<li>Rhesus incompatibility</li>
|
||||
<li>ABO incompatibility</li>
|
||||
<li>Other blood group isoimmunization</li>
|
||||
<li>Use of drugs during labor (oxytocin, promethazine, bupivacaine)</li>
|
||||
<li>Exclusive breastfeeding</li>
|
||||
</ul>
|
||||
<p style="font-weight:600;color:var(--g800);margin:0 0 6px;">Perinatal Factors</p>
|
||||
<ul style="margin:0 0 12px;padding-left:18px;color:var(--g700);line-height:1.6;">
|
||||
<li>Mode of delivery (breech vs vertex, instrumentation)</li>
|
||||
<li>Birth trauma (cephalohematoma, substantial bruising, extravasation)</li>
|
||||
<li>Birth asphyxia</li>
|
||||
<li>Congenital infections (CMV, syphilis)</li>
|
||||
<li>Sepsis</li>
|
||||
</ul>
|
||||
<p style="font-weight:600;color:var(--g800);margin:0 0 6px;">Neonatal Factors</p>
|
||||
<ul style="margin:0 0 12px;padding-left:18px;color:var(--g700);line-height:1.6;">
|
||||
<li>Male sex</li>
|
||||
<li>Prematurity or low birthweight and small-for-gestational age</li>
|
||||
<li>Hypothyroidism</li>
|
||||
<li>Polycythemia</li>
|
||||
<li>Hypoglycemia</li>
|
||||
<li>Low intake of breast milk, dehydration, or weight loss</li>
|
||||
<li>Breast milk jaundice</li>
|
||||
<li>Jaundice in the first day of life</li>
|
||||
<li>Trisomy 21</li>
|
||||
<li>Infant of diabetic mother</li>
|
||||
<li>Cephalohematoma, other bruising</li>
|
||||
</ul>
|
||||
<p style="font-weight:600;color:var(--g800);margin:0 0 6px;">Other Risk Factors and Markers</p>
|
||||
<ul style="margin:0 0 8px;padding-left:18px;color:var(--g700);line-height:1.6;">
|
||||
<li>Previous sibling received phototherapy or exchange transfusion</li>
|
||||
<li>Predischarge TSB or TcB in the high zone</li>
|
||||
<li>Use of hemolytic agents (naphthalene, menthol-based products) in G6PD-deficient populations</li>
|
||||
<li>Folate deficiency</li>
|
||||
<li>Aflatoxins</li>
|
||||
<li>Hypothermia</li>
|
||||
<li>Birth outside of a healthcare facility</li>
|
||||
</ul>
|
||||
<p style="margin:8px 0 0;font-size:11px;color:var(--g400);">Modified from Olusanya BO, Kaplan M, Hansen TWR. Neonatal hyperbilirubinaemia: a global perspective. Lancet Child Adolesc Health. 2018;2:610-618. Nelson Textbook of Pediatrics, Table 137.1.</p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Gestational Age (completed weeks)</label>
|
||||
<select id="bili-ga">
|
||||
<option value="">Select</option>
|
||||
<option value="35">35 weeks</option>
|
||||
<option value="36">36 weeks</option>
|
||||
<option value="37">37 weeks</option>
|
||||
<option value="38">38 weeks</option>
|
||||
<option value="39">39 weeks</option>
|
||||
<option value="40">40+ weeks</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--g500);margin-top:2px;">AAP 2022 thresholds depend only on completed weeks; days do not change the curve.</div>
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Age of Infant (hours)</label>
|
||||
<input type="number" id="bili-age-hours" min="0" max="168" step="1" placeholder="e.g. 48">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Total Serum Bilirubin (mg/dL)</label>
|
||||
<input type="number" id="bili-tsb" min="0" max="35" step="0.1" placeholder="e.g. 12.5">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Neurotoxicity risk factors</label>
|
||||
<select id="bili-risk">
|
||||
<option value="none">Absent</option>
|
||||
<option value="medium">Present (any one qualifies)</option>
|
||||
</select>
|
||||
<details style="font-size:11px;color:var(--g500);margin-top:4px;line-height:1.5;">
|
||||
<summary style="cursor:pointer;color:var(--g600);">What counts as a neurotoxicity risk factor?</summary>
|
||||
<ul style="margin:6px 0 0 16px;padding:0;">
|
||||
<li>Isoimmune hemolytic disease (positive DAT — ABO or Rh incompatibility)</li>
|
||||
<li>G6PD deficiency</li>
|
||||
<li>Other hemolytic disease (congenital or acquired)</li>
|
||||
<li>Sepsis</li>
|
||||
<li>Albumin < 3.0 g/dL</li>
|
||||
<li>Significant clinical instability in the previous 24 hours</li>
|
||||
</ul>
|
||||
<div style="margin-top:6px;">Any one qualifies. Per AAP 2022 Box 2. Gestational age < 38w is also a neurotoxicity risk per the guideline but is already accounted for by the per-week threshold curves — do NOT double-count it here.</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-calc-bili-aap" class="btn-sm btn-primary" style="margin-top:12px;"><i class="fas fa-calculator"></i> Assess</button>
|
||||
<button id="btn-clear-bili-aap" class="btn-sm btn-ghost" style="margin-top:12px;">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="bili-bhutani-panel" style="display:none;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Bhutani hour-specific bilirubin nomogram. Determines risk zone for infants >= 35 weeks GA.</p>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Age of Infant (hours)</label>
|
||||
<input type="number" id="bhutani-age" min="12" max="144" step="1" placeholder="12-144">
|
||||
</div>
|
||||
<div class="calc-field">
|
||||
<label>Total Serum Bilirubin (mg/dL)</label>
|
||||
<input type="number" id="bhutani-tsb" min="0" max="25" step="0.1" placeholder="e.g. 8.5">
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-calc-bhutani" class="btn-sm btn-primary" style="margin-top:12px;"><i class="fas fa-calculator"></i> Assess</button>
|
||||
<button id="btn-clear-bhutani" class="btn-sm btn-ghost" style="margin-top:12px;">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="bili-result" class="calc-result hidden"></div>
|
||||
<div id="bili-chart-container" class="chart-container hidden"><canvas id="bili-chart-canvas"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ VITAL SIGNS BY AGE ═══════════ -->
|
||||
<div id="calc-vitals" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-heartbeat"></i> Normal Vital Signs by Age</h3>
|
||||
<span style="font-size:11px;color:var(--g500);">Harriet Lane Handbook</span>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Select age group to view normal vital sign ranges. Source: Harriet Lane Handbook.</p>
|
||||
|
||||
<div class="calc-field" style="max-width:300px;margin-bottom:16px;">
|
||||
<label>Age Group</label>
|
||||
<select id="vitals-age-select">
|
||||
<option value="">Select age group</option>
|
||||
<option value="premie">Premie</option>
|
||||
<option value="0-3mo">0-3 months</option>
|
||||
<option value="3-6mo">3-6 months</option>
|
||||
<option value="6-12mo">6-12 months</option>
|
||||
<option value="1-3yr">1-3 years</option>
|
||||
<option value="3-6yr">3-6 years</option>
|
||||
<option value="6-12yr">6-12 years</option>
|
||||
<option value=">12yr">>12 years</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="vitals-result" class="hidden"></div>
|
||||
|
||||
<!-- All ages reference table (collapsible) -->
|
||||
<details style="margin-top:16px;border:1px solid var(--g200);border-radius:8px;">
|
||||
<summary style="padding:10px 14px;cursor:pointer;font-weight:600;font-size:13px;color:var(--g700);background:var(--g50);border-radius:8px;">View All Age Groups</summary>
|
||||
<div style="padding:12px;overflow-x:auto;">
|
||||
<table class="admin-table" style="font-size:12px;width:100%;">
|
||||
<thead>
|
||||
<tr><th>Age</th><th>HR (bpm)</th><th>RR</th><th>BP (mmHg)</th><th>Temp (C)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Premie</td><td>120-170</td><td>40-70</td><td>55-75/35-45</td><td>36.5-37.5</td></tr>
|
||||
<tr><td>0-3 mo</td><td>100-150</td><td>35-55</td><td>65-85/45-55</td><td>36.5-37.5</td></tr>
|
||||
<tr><td>3-6 mo</td><td>90-120</td><td>30-45</td><td>70-90/50-65</td><td>36.5-37.5</td></tr>
|
||||
<tr><td>6-12 mo</td><td>80-120</td><td>25-40</td><td>80-100/55-65</td><td>36.0-37.5</td></tr>
|
||||
<tr><td>1-3 yr</td><td>70-110</td><td>20-30</td><td>90-105/55-70</td><td>36.0-37.5</td></tr>
|
||||
<tr><td>3-6 yr</td><td>65-110</td><td>20-25</td><td>95-110/60-75</td><td>36.0-37.5</td></tr>
|
||||
<tr><td>6-12 yr</td><td>60-95</td><td>14-22</td><td>100-120/60-75</td><td>36.0-37.5</td></tr>
|
||||
<tr><td>>12 yr</td><td>55-85</td><td>12-18</td><td>110-135/65-85</td><td>36.0-37.5</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="margin-top:16px;">
|
||||
<h4 style="font-size:13px;font-weight:700;color:var(--g700);margin:0 0 8px;">Quick Reference Formulas</h4>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
|
||||
<div style="padding:10px;background:var(--g50);border-radius:8px;font-size:12px;">
|
||||
<strong>Estimated Weight:</strong><br>
|
||||
1-12mo: (age in mo + 9) / 2<br>
|
||||
1-5yr: 2 x (age in yr) + 8<br>
|
||||
6-12yr: 3 x (age in yr) + 7
|
||||
</div>
|
||||
<div style="padding:10px;background:var(--g50);border-radius:8px;font-size:12px;">
|
||||
<strong>Min Systolic BP:</strong><br>
|
||||
1-10yr: 70 + (2 x age in yr)<br>
|
||||
Hypotension: SBP < 5th %ile
|
||||
</div>
|
||||
<div style="padding:10px;background:var(--g50);border-radius:8px;font-size:12px;">
|
||||
<strong>ETT Size:</strong><br>
|
||||
Uncuffed: (age/4) + 4<br>
|
||||
Cuffed: (age/4) + 3.5<br>
|
||||
Depth: ETT size x 3
|
||||
</div>
|
||||
<div style="padding:10px;background:var(--g50);border-radius:8px;font-size:12px;">
|
||||
<strong>Maintenance Fluids (4-2-1):</strong><br>
|
||||
First 10kg: 4 mL/kg/hr<br>
|
||||
Next 10kg: +2 mL/kg/hr<br>
|
||||
Each kg above 20: +1 mL/kg/hr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ RESUSCITATION MEDICATIONS ═══════════ -->
|
||||
<div id="calc-resus" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-kit-medical"></i> Resuscitation Medications</h3>
|
||||
<span style="font-size:11px;color:var(--g500);">Harriet Lane Handbook / AHA PALS 2020</span>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Enter patient weight to calculate all resuscitation medication doses.</p>
|
||||
<div class="calc-grid">
|
||||
<div class="calc-field">
|
||||
<label>Patient Weight (kg)</label>
|
||||
<input type="number" id="resus-weight" min="0.5" max="100" step="0.1" placeholder="e.g. 15">
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-calc-resus" class="btn-sm btn-primary" style="margin-top:12px;"><i class="fas fa-calculator"></i> Calculate All Doses</button>
|
||||
<button id="btn-clear-resus" class="btn-sm btn-ghost" style="margin-top:12px;">Clear</button>
|
||||
<div id="resus-result" class="calc-result hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ GLASGOW COMA SCALE ═══════════ -->
|
||||
<div id="calc-gcs" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-brain"></i> Glasgow Coma Scale</h3>
|
||||
<span style="font-size:11px;color:var(--g500);">Child/Adult and Infant versions</span>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Select responses to calculate GCS. Total score 3-15. Source: Harriet Lane Handbook.</p>
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
|
||||
<button class="calc-pill active" data-gcs="child">Child / Adult</button>
|
||||
<button class="calc-pill" data-gcs="infant">Infant</button>
|
||||
</div>
|
||||
|
||||
<!-- Child/Adult GCS -->
|
||||
<div id="gcs-child-panel">
|
||||
<div class="calc-field" style="margin-bottom:12px;">
|
||||
<label>Eye Opening</label>
|
||||
<select id="gcs-child-eye">
|
||||
<option value="4">4 — Spontaneous</option>
|
||||
<option value="3">3 — To speech</option>
|
||||
<option value="2">2 — To pain</option>
|
||||
<option value="1">1 — None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="margin-bottom:12px;">
|
||||
<label>Verbal Response</label>
|
||||
<select id="gcs-child-verbal">
|
||||
<option value="5">5 — Oriented</option>
|
||||
<option value="4">4 — Confused</option>
|
||||
<option value="3">3 — Inappropriate words</option>
|
||||
<option value="2">2 — Incomprehensible sounds</option>
|
||||
<option value="1">1 — None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="margin-bottom:12px;">
|
||||
<label>Motor Response</label>
|
||||
<select id="gcs-child-motor">
|
||||
<option value="6">6 — Obeys commands</option>
|
||||
<option value="5">5 — Localizes pain</option>
|
||||
<option value="4">4 — Withdraws to pain</option>
|
||||
<option value="3">3 — Abnormal flexion (decorticate)</option>
|
||||
<option value="2">2 — Abnormal extension (decerebrate)</option>
|
||||
<option value="1">1 — None (flaccid)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infant GCS -->
|
||||
<div id="gcs-infant-panel" style="display:none;">
|
||||
<div class="calc-field" style="margin-bottom:12px;">
|
||||
<label>Eye Opening</label>
|
||||
<select id="gcs-infant-eye">
|
||||
<option value="4">4 — Spontaneous</option>
|
||||
<option value="3">3 — To speech/sound</option>
|
||||
<option value="2">2 — To painful stimuli</option>
|
||||
<option value="1">1 — None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="margin-bottom:12px;">
|
||||
<label>Verbal Response</label>
|
||||
<select id="gcs-infant-verbal">
|
||||
<option value="5">5 — Coos/babbles</option>
|
||||
<option value="4">4 — Irritable cry</option>
|
||||
<option value="3">3 — Cries to pain</option>
|
||||
<option value="2">2 — Moans to pain</option>
|
||||
<option value="1">1 — None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calc-field" style="margin-bottom:12px;">
|
||||
<label>Motor Response</label>
|
||||
<select id="gcs-infant-motor">
|
||||
<option value="6">6 — Normal spontaneous movement</option>
|
||||
<option value="5">5 — Withdraws to touch</option>
|
||||
<option value="4">4 — Withdraws to pain</option>
|
||||
<option value="3">3 — Abnormal flexion (decorticate)</option>
|
||||
<option value="2">2 — Abnormal extension (decerebrate)</option>
|
||||
<option value="1">1 — None (flaccid)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="gcs-result" class="calc-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ EQUIPMENT SIZING ═══════════ -->
|
||||
<div id="calc-equipment" class="calc-panel hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-lungs"></i> Pediatric Equipment Sizing</h3>
|
||||
<span style="font-size:11px;color:var(--g500);">Harriet Lane Handbook</span>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Select age/weight group to view recommended equipment sizes.</p>
|
||||
<div class="calc-field" style="max-width:300px;margin-bottom:16px;">
|
||||
<label>Age / Weight Group</label>
|
||||
<select id="equip-age-select">
|
||||
<option value="">Select</option>
|
||||
<option value="premie">Premie (1-3 kg)</option>
|
||||
<option value="newborn">Newborn (2-4 kg)</option>
|
||||
<option value="6mo">6 months (6-8 kg)</option>
|
||||
<option value="1yr">1 year (10 kg)</option>
|
||||
<option value="2-3yr">2-3 years (12-16 kg)</option>
|
||||
<option value="4-6yr">4-6 years (20-25 kg)</option>
|
||||
<option value="7-10yr">7-10 years (25-35 kg)</option>
|
||||
<option value="11-15yr">11-15 years (40-50 kg)</option>
|
||||
<option value="16yr">16+ years (>50 kg)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="equip-result" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Image lightbox moved to index.html so it exists in the DOM
|
||||
regardless of which tab is loaded (NRP + seizure pathway images
|
||||
on the Bedside tab broke when this markup stayed here). -->
|
||||
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-rotate"></i> Catch-Up Schedule</h2>
|
||||
<p>ACIP catch-up immunization schedule for persons aged 4 months–18 years</p>
|
||||
</div>
|
||||
<div id="wv-panel-catchup">
|
||||
<p class="wv-loading">Loading catch-up schedule...</p>
|
||||
</div>
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-clipboard-list"></i> Chart Review / Precharting</h2>
|
||||
<p>Paste clinic notes, subspecialty notes, ED notes, labs → AI summarizes</p>
|
||||
</div>
|
||||
|
||||
<div class="demographics-bar">
|
||||
<div class="demo-field"><label>Patient Age</label><input type="text" id="cr-age" placeholder="e.g., 8 years"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="cr-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
|
||||
<div class="demo-field"><label>PMH</label><input type="text" id="cr-pmh" placeholder="e.g., hypothyroidism, vitiligo"></div>
|
||||
<div class="demo-field">
|
||||
<label>Review Type</label>
|
||||
<select id="cr-type">
|
||||
<option value="outpatient">Outpatient Clinic</option>
|
||||
<option value="subspecialty">Subspecialty</option>
|
||||
<option value="ed">ED Visit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar-wrap">
|
||||
<div class="save-bar" id="chart-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="chart-label" class="save-label-input" placeholder="Patient label (e.g. John D, Visit #42)">
|
||||
</div>
|
||||
<button id="btn-chart-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-chart-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
<button id="btn-chart-new" class="btn-sm btn-ghost" title="Clear all and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
|
||||
</div>
|
||||
<div id="chart-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="chart-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="chart-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visits -->
|
||||
<div id="cr-visits-container">
|
||||
<div class="card visit-card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar"></i> Visit / Note #1</h3>
|
||||
<button class="btn-sm btn-ghost remove-visit-btn"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="note-entry">
|
||||
<div class="note-meta">
|
||||
<input type="date" class="visit-date">
|
||||
<select class="visit-type">
|
||||
<option value="outpatient">Outpatient Visit</option>
|
||||
<option value="subspecialty">Subspecialty Note</option>
|
||||
<option value="ed">ED Visit</option>
|
||||
</select>
|
||||
<input type="text" class="visit-specialist" placeholder="Specialist name (if subspecialty)">
|
||||
<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)">
|
||||
</div>
|
||||
<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>
|
||||
<textarea class="visit-labs" placeholder="Labs from this visit (optional)" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="cr-add-visit" class="btn-sm btn-ghost" style="margin:8px 0"><i class="fas fa-plus"></i> Add Visit/Note</button>
|
||||
|
||||
<!-- Labs -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-flask"></i> Additional Labs</h3><button id="cr-add-lab" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add</button></div>
|
||||
<div id="cr-labs-container">
|
||||
<div class="lab-entry">
|
||||
<input type="date" class="lab-date">
|
||||
<textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Instructions (optional)</h3></div>
|
||||
<textarea id="cr-instructions" class="full-input" rows="3" placeholder="e.g., 'Focus on thyroid management', 'Include medication changes'"></textarea>
|
||||
</div>
|
||||
|
||||
<button id="cr-generate-btn" class="btn-generate btn-generate-green"><i class="fas fa-wand-magic-sparkles"></i> Generate Chart Review</button>
|
||||
|
||||
<div id="cr-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Chart Review</h3>
|
||||
<div class="output-actions">
|
||||
<span id="cr-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="cr-review-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="cr-review-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="cr-review-text" data-label="chart-review"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cr-review-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<textarea id="cr-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify..."></textarea>
|
||||
<button class="btn-sm btn-primary" id="cr-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="cr-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-pen-to-square"></i> Content Manager</h2>
|
||||
<p>Create and manage Learning Hub content, quizzes, and categories</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="cms-stats-bar" id="lh-cms-stats-bar">
|
||||
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-published">-</span><span class="cms-stat-label">Published</span></div>
|
||||
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-drafts">-</span><span class="cms-stat-label">Drafts</span></div>
|
||||
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-categories">-</span><span class="cms-stat-label">Categories</span></div>
|
||||
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-quizzes">-</span><span class="cms-stat-label">Quizzes</span></div>
|
||||
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-attempts">-</span><span class="cms-stat-label">Attempts</span></div>
|
||||
</div>
|
||||
|
||||
<!-- CMS Layout: sidebar + main -->
|
||||
<div class="cms-layout">
|
||||
|
||||
<!-- Sidebar: Categories + filters -->
|
||||
<aside class="cms-sidebar">
|
||||
<div class="cms-sidebar-section">
|
||||
<h4><i class="fas fa-folder-tree"></i> Categories</h4>
|
||||
<div id="lh-cms-categories" class="cms-cat-list"></div>
|
||||
<div class="cms-add-cat">
|
||||
<input type="text" id="lh-cms-cat-name" placeholder="New category..." class="cms-input-sm">
|
||||
<button id="btn-lh-add-cat" class="cms-btn-add" title="Add category"><i class="fas fa-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cms-sidebar-section">
|
||||
<h4><i class="fas fa-filter"></i> Filter</h4>
|
||||
<select id="cms-filter-status" class="cms-input-sm" style="width:100%;margin-bottom:6px;">
|
||||
<option value="all">All Status</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Drafts</option>
|
||||
</select>
|
||||
<select id="cms-filter-category" class="cms-input-sm" style="width:100%;">
|
||||
<option value="all">All Categories</option>
|
||||
</select>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main: Content list / Editor -->
|
||||
<div class="cms-main">
|
||||
|
||||
<!-- Content list view -->
|
||||
<div id="cms-list-view">
|
||||
<div class="cms-toolbar">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button type="button" id="btn-lh-new-content" class="btn-sm btn-primary" data-type="article"><i class="fas fa-file-alt"></i> New Article</button>
|
||||
<button type="button" id="btn-lh-new-quiz" class="btn-sm" style="background:var(--amber-light);color:#92400e;border:1px solid var(--amber);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-clipboard-question"></i> New Quiz</button>
|
||||
<button type="button" id="btn-lh-new-pearl" class="btn-sm" style="background:var(--purple-light);color:var(--purple);border:1px solid var(--purple);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-gem"></i> New Pearl</button>
|
||||
<button type="button" id="btn-lh-new-presentation" class="btn-sm" style="background:#ecfdf5;color:#065f46;border:1px solid #6ee7b7;border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-presentation-screen"></i> New Presentation</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button type="button" id="btn-lh-refresh-content" class="btn-sm btn-ghost" title="Refresh content list"><i class="fas fa-rotate"></i></button>
|
||||
<div class="cms-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="cms-search" placeholder="Search content..." class="cms-input-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cms-content-table">
|
||||
<div class="cms-table-header">
|
||||
<span class="cms-col-title">Title</span>
|
||||
<span class="cms-col-cat">Category</span>
|
||||
<span class="cms-col-type">Type</span>
|
||||
<span class="cms-col-status">Status</span>
|
||||
<span class="cms-col-date">Updated</span>
|
||||
<span class="cms-col-actions"></span>
|
||||
</div>
|
||||
<div id="lh-cms-content-list" class="cms-table-body">
|
||||
<div style="text-align:center;padding:40px;color:var(--g400);">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor view (hidden until edit/new) -->
|
||||
<div id="lh-cms-editor" class="cms-editor hidden">
|
||||
<div class="cms-editor-header">
|
||||
<button type="button" id="btn-lh-close-editor" class="btn-sm btn-ghost"><i class="fas fa-arrow-left"></i> Back to List</button>
|
||||
<div class="cms-editor-actions">
|
||||
<button type="button" id="btn-lh-ai-open" class="btn-sm" style="background:linear-gradient(135deg,#7c3aed,#2563eb);color:white;border:none;"><i class="fas fa-wand-magic-sparkles"></i> AI Generate</button>
|
||||
<button type="button" id="btn-lh-delete-content" class="btn-sm hidden" style="background:var(--red-light);color:var(--red);"><i class="fas fa-trash"></i> Delete</button>
|
||||
<select id="lh-cms-edit-published" class="cms-input-sm">
|
||||
<option value="false">Draft</option>
|
||||
<option value="true">Published</option>
|
||||
</select>
|
||||
<button type="button" id="btn-lh-save-content" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Inline delete confirmation bar — message built dynamically in JS -->
|
||||
<div id="lh-delete-confirm-bar" class="lh-delete-confirm-bar hidden">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
<span id="lh-delete-confirm-msg"></span>
|
||||
<div style="display:flex;gap:8px;margin-left:auto;flex-shrink:0;">
|
||||
<button type="button" id="btn-lh-delete-cancel" class="btn-sm btn-ghost">Cancel</button>
|
||||
<button type="button" id="btn-lh-delete-yes" class="btn-sm" style="background:var(--red);color:white;">Yes, Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="lh-cms-edit-id" value="">
|
||||
|
||||
<!-- Title -->
|
||||
<input type="text" id="lh-cms-edit-title" class="cms-title-input" placeholder="Enter title...">
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="cms-meta-row">
|
||||
<div class="cms-meta-field">
|
||||
<label>Category</label>
|
||||
<select id="lh-cms-edit-category" class="cms-input-sm">
|
||||
<option value="">Uncategorized</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cms-meta-field">
|
||||
<label>Subject</label>
|
||||
<input type="text" id="lh-cms-edit-subject" class="cms-input-sm" placeholder="e.g., Development">
|
||||
</div>
|
||||
<div class="cms-meta-field">
|
||||
<label>Type</label>
|
||||
<select id="lh-cms-edit-type" class="cms-input-sm">
|
||||
<option value="article">Article</option>
|
||||
<option value="quiz">Quiz Only</option>
|
||||
<option value="pearl">Clinical Pearl</option>
|
||||
<option value="presentation">Presentation</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Generate panel -->
|
||||
<div id="lh-ai-panel" class="lh-ai-panel hidden">
|
||||
<div class="lh-ai-panel-header">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span style="font-size:18px;">✨</span>
|
||||
<strong style="font-size:14px;color:var(--g800);">AI Content Generator</strong>
|
||||
</div>
|
||||
<button id="btn-lh-ai-close" class="btn-sm btn-ghost" style="padding:2px 8px;">✕ Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Source tabs -->
|
||||
<div class="lh-ai-tabs">
|
||||
<button class="lh-ai-tab active" data-aitab="topic"><i class="fas fa-lightbulb"></i> Describe Content</button>
|
||||
<button class="lh-ai-tab" data-aitab="upload"><i class="fas fa-file-upload"></i> Upload File</button>
|
||||
<button class="lh-ai-tab" data-aitab="webdav" id="lh-ai-tab-webdav"><i class="fas fa-cloud"></i> Nextcloud</button>
|
||||
</div>
|
||||
|
||||
<!-- Topic tab -->
|
||||
<div class="lh-ai-tabpanel" id="lh-ai-tp-topic">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);">Tell AI what to create</label>
|
||||
<textarea id="lh-ai-topic" class="cms-input-sm" style="width:100%;min-height:72px;resize:vertical;font-family:inherit;line-height:1.5;" placeholder="Describe the content you want AI to generate, e.g.: Write an article on febrile seizures in children covering causes, evaluation, and management. Create a clinical pearl on neonatal jaundice with key decision points. Build a quiz on pediatric asthma classification and treatment ladder."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload tab -->
|
||||
<div class="lh-ai-tabpanel hidden" id="lh-ai-tp-upload">
|
||||
<label class="lh-ai-dropzone" id="lh-ai-dropzone">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:28px;color:var(--blue);margin-bottom:8px;display:block;"></i>
|
||||
<span id="lh-ai-file-label">Drop files here or click to browse</span>
|
||||
<small style="color:var(--g400);display:block;margin-top:4px;">PDF, TXT, MD, HTML — max 100 MB each, up to 10 files</small>
|
||||
<input type="file" id="lh-ai-file" accept=".pdf,.txt,.md,.html,.htm,.csv,.json" multiple style="display:none;">
|
||||
</label>
|
||||
<div id="lh-ai-files-list" style="margin-top:8px;display:none;"></div>
|
||||
<div class="form-group" style="margin:8px 0 0;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);">Topic / context <span style="color:var(--g400);font-weight:400;">(optional — helps AI focus)</span></label>
|
||||
<input type="text" id="lh-ai-upload-context" class="cms-input-sm" style="width:100%;" placeholder="e.g., Pediatric asthma management, focus on treatment ladder">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebDAV tab -->
|
||||
<div class="lh-ai-tabpanel hidden" id="lh-ai-tp-webdav">
|
||||
<!-- File browser — hidden once a file is selected -->
|
||||
<div id="lh-ai-webdav-browser">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<span id="lh-ai-webdav-path-label" style="font-size:12px;color:var(--g500);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">/</span>
|
||||
<button id="btn-lh-webdav-refresh" class="btn-sm btn-ghost" style="padding:3px 8px;font-size:12px;"><i class="fas fa-sync"></i></button>
|
||||
</div>
|
||||
<div id="lh-ai-webdav-list" style="max-height:200px;overflow-y:auto;border:1px solid var(--g200);border-radius:8px;background:white;">
|
||||
<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;">Click to browse your Nextcloud files</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected file indicator — shown after selection, replaces browser -->
|
||||
<div id="lh-ai-webdav-selected" style="display:none;flex-direction:column;gap:8px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--blue-light);border-radius:6px;font-size:13px;color:var(--blue);">
|
||||
<i class="fas fa-file-check"></i>
|
||||
<span id="lh-ai-webdav-selected-name" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
|
||||
<button id="btn-lh-webdav-deselect" style="background:none;border:none;cursor:pointer;color:var(--blue);padding:0;font-size:16px;line-height:1;" title="Remove selection">✕</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);">Topic / context <span style="color:var(--g400);font-weight:400;">(optional — helps AI focus)</span></label>
|
||||
<input type="text" id="lh-ai-webdav-context" class="cms-input-sm" style="width:100%;" placeholder="e.g., Focus on management guidelines for pediatric residents">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generation options -->
|
||||
<div class="lh-ai-options">
|
||||
<!-- Row 1: type + model -->
|
||||
<div class="lh-ai-opt-row">
|
||||
<div class="lh-ai-opt-field">
|
||||
<label>Content type</label>
|
||||
<select id="lh-ai-ctype" class="cms-input-sm">
|
||||
<option value="article">Article</option>
|
||||
<option value="quiz">Quiz</option>
|
||||
<option value="pearl">Clinical Pearl</option>
|
||||
<option value="presentation">Presentation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lh-ai-opt-field" style="flex:2;">
|
||||
<label>Model</label>
|
||||
<select id="lh-ai-model" class="cms-input-sm tab-model-select" style="width:100%;"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: context-aware size options (shown/hidden via style.display in JS) -->
|
||||
<div class="lh-ai-opt-row" id="lh-ai-size-row">
|
||||
<div id="lh-ai-words-field" style="display:flex;flex-direction:column;gap:3px;">
|
||||
<label style="font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;">Approx. word count <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
|
||||
<input type="number" id="lh-ai-word-count" value="" min="100" max="3000" step="50" class="cms-input-sm" style="width:110px;" placeholder="e.g. 600">
|
||||
</div>
|
||||
<div id="lh-ai-slides-field" style="display:none;flex-direction:column;gap:3px;">
|
||||
<label style="font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;">Number of slides <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
|
||||
<input type="number" id="lh-ai-slide-count" value="" min="3" max="30" class="cms-input-sm" style="width:90px;" placeholder="e.g. 10">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: quiz questions card -->
|
||||
<div id="lh-ai-quiz-row" class="lh-ai-quiz-card">
|
||||
<div class="lh-ai-quiz-card-header" id="lh-ai-quiz-card-header">
|
||||
<i class="fas fa-clipboard-question" style="color:var(--blue);font-size:14px;"></i>
|
||||
<span id="lh-ai-quiz-card-title" style="font-weight:600;font-size:13px;color:var(--g800);">Quiz Questions</span>
|
||||
</div>
|
||||
<div class="lh-ai-quiz-card-body">
|
||||
<label id="lh-ai-q-toggle-label" style="display:none;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--g700);margin:0;">
|
||||
<input type="checkbox" id="lh-ai-q-toggle" style="accent-color:var(--blue);width:15px;height:15px;flex-shrink:0;">
|
||||
Also generate quiz questions for this content
|
||||
</label>
|
||||
<div id="lh-ai-q-count-wrap" style="display:flex;align-items:center;gap:8px;">
|
||||
<label style="font-size:12px;color:var(--g600);margin:0;">Number of questions</label>
|
||||
<input type="number" id="lh-ai-q-count" value="5" min="1" max="20" class="cms-input-sm" style="width:70px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: instructions -->
|
||||
<div class="lh-ai-opt-field" style="width:100%;">
|
||||
<label>Special instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
|
||||
<textarea id="lh-ai-refinement" class="cms-input-sm" style="width:100%;min-height:90px;resize:vertical;font-family:inherit;line-height:1.5;" placeholder="e.g., Focus on ER management, suitable for residents, case-based format"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button type="button" id="btn-lh-ai-generate" class="btn-sm btn-primary" style="flex:1;padding:10px;">
|
||||
<i class="fas fa-wand-magic-sparkles"></i> Generate Content
|
||||
</button>
|
||||
<button type="button" id="btn-lh-ai-refine-body" class="btn-sm btn-ghost" style="padding:10px;">
|
||||
<i class="fas fa-pen-to-square"></i> Refine Body
|
||||
</button>
|
||||
</div>
|
||||
<!-- Inline refine input bar — shown when Refine Body is clicked -->
|
||||
<div id="lh-ai-refine-bar" style="display:none;gap:6px;align-items:center;padding:10px 12px;background:#fffbeb;border:1.5px solid var(--amber);border-radius:8px;margin-top:4px;">
|
||||
<input type="text" id="lh-ai-refine-input" class="cms-input-sm" style="flex:1;" placeholder='e.g. "Make it more concise" or "Add a clinical case example"'>
|
||||
<button id="btn-lh-refine-submit" class="btn-sm btn-primary" style="padding:5px 14px;white-space:nowrap;">Apply</button>
|
||||
<button id="btn-lh-refine-cancel" class="btn-sm btn-ghost" style="padding:5px 10px;">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body editor (Tiptap rich text) — hidden for presentations -->
|
||||
<div id="lh-body-section" class="cms-body-section">
|
||||
<label class="cms-label">Body</label>
|
||||
<div id="lh-body-editor" class="cms-quill-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- Marp markdown editor — shown only for presentations -->
|
||||
<div id="lh-marp-section" class="cms-body-section hidden">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
||||
<label class="cms-label" style="margin:0;">Slides <span style="font-size:11px;color:var(--g400);font-weight:400;">(Marp markdown — separate slides with <code>---</code>)</span></label>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button type="button" id="btn-lh-preview-slides" class="btn-sm btn-ghost" style="font-size:12px;"><i class="fas fa-eye"></i> Preview</button>
|
||||
<button type="button" id="btn-lh-download-pptx" class="btn-sm" style="background:#065f46;color:white;font-size:12px;border:none;border-radius:6px;padding:5px 12px;cursor:pointer;"><i class="fas fa-file-powerpoint"></i> Download PPTX</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="lh-marp-editor" class="lh-marp-textarea" placeholder="✨ Click 'AI Generate' above to create your slides automatically.
|
||||
|
||||
After generation, you can edit the Marp markdown here if needed.
|
||||
Each slide is separated by ---
|
||||
|
||||
Example structure:
|
||||
# Title Slide
|
||||
Subtitle here
|
||||
---
|
||||
# Topic Slide
|
||||
- Key point one
|
||||
- Key point two"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Quiz builder -->
|
||||
<div class="cms-quiz-section">
|
||||
<div class="cms-quiz-header">
|
||||
<h3><i class="fas fa-clipboard-question"></i> Quiz Questions</h3>
|
||||
<button type="button" id="btn-lh-add-question" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add Question</button>
|
||||
</div>
|
||||
<div id="lh-cms-questions" class="cms-questions-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-microphone"></i> Voice Dictation → HPI</h2>
|
||||
<p>Dictate your narrative → AI restructures into polished HPI</p>
|
||||
</div>
|
||||
|
||||
<div class="demographics-bar">
|
||||
<div class="demo-field"><label>Patient Age</label><input type="text" id="dict-age" placeholder="e.g., 8 months"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="dict-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
|
||||
<div class="demo-field">
|
||||
<label>Setting</label>
|
||||
<select id="dict-setting">
|
||||
<option value="outpatient">Outpatient</option>
|
||||
<option value="inpatient">Inpatient/Floors</option>
|
||||
<option value="ed">Emergency Dept</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar-wrap">
|
||||
<div class="save-bar" id="dict-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="dict-label" class="save-label-input" placeholder="Patient label (e.g. John D, Visit #42)">
|
||||
</div>
|
||||
<button id="btn-dict-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-dict-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
<button id="btn-dict-new" class="btn-sm btn-ghost" title="Clear all and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
|
||||
</div>
|
||||
<div id="dict-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="dict-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="dict-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="record-controls">
|
||||
<button id="dict-record-btn" class="record-btn btn-purple"><i class="fas fa-microphone"></i><span>Start Dictation</span></button>
|
||||
<button id="dict-pause-btn" class="btn-sm btn-ghost hidden" style="margin-left:8px;"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="dict-recording-indicator" class="recording-indicator hidden"><div class="pulse-dot pulse-purple"></div><span>Dictating... <span id="dict-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-file-lines"></i> Dictation</h3><button id="dict-clear" class="btn-sm btn-ghost"><i class="fas fa-eraser"></i> Clear</button></div>
|
||||
<div id="dict-transcript" class="editable-box" contenteditable="true" data-placeholder="Dictation appears here or type directly..."></div>
|
||||
</div>
|
||||
|
||||
<div class="soap-options">
|
||||
<label>Generate as:</label>
|
||||
<select id="dict-output-type">
|
||||
<option value="hpi">HPI Only</option>
|
||||
<option value="soap-full">Full SOAP Note</option>
|
||||
<option value="soap-subjective">SOAP Subjective Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="dict-generate-btn" class="btn-generate btn-generate-purple"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>
|
||||
|
||||
<div id="dict-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Output</h3>
|
||||
<div class="output-actions">
|
||||
<span id="dict-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="dict-hpi-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="dict-hpi-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="dict-hpi-text" data-label="hpi-dictation"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dict-hpi-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<textarea id="dict-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify..."></textarea>
|
||||
<button class="btn-sm btn-primary" id="dict-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="dict-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-comments"></i> Live Encounter → HPI</h2>
|
||||
<p>Record doctor-patient conversation → AI generates structured HPI</p>
|
||||
</div>
|
||||
|
||||
<div class="demographics-bar">
|
||||
<div class="demo-field">
|
||||
<label>Patient Age</label>
|
||||
<input type="text" id="enc-age" placeholder="e.g., 4 years">
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Gender</label>
|
||||
<select id="enc-gender"><option value="">Select</option><option>Male</option><option>Female</option></select>
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Setting</label>
|
||||
<select id="enc-setting">
|
||||
<option value="outpatient">Outpatient</option>
|
||||
<option value="inpatient">Inpatient/Floors</option>
|
||||
<option value="ed">Emergency Dept</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar-wrap">
|
||||
<div class="save-bar" id="enc-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="enc-label" class="save-label-input" placeholder="Patient label (e.g. John D, Visit #42)">
|
||||
</div>
|
||||
<button id="btn-enc-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-enc-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
<button id="btn-enc-new" class="btn-sm btn-ghost" title="Clear all and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
|
||||
</div>
|
||||
<div id="enc-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="enc-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="enc-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="record-controls">
|
||||
<button id="enc-record-btn" class="record-btn"><i class="fas fa-microphone"></i><span>Start Recording</span></button>
|
||||
<button id="enc-pause-btn" class="btn-sm btn-ghost hidden" style="margin-left:8px;"><i class="fas fa-pause"></i> Pause</button>
|
||||
<button id="enc-stop-btn" class="btn-sm hidden" style="margin-left:8px;background:var(--red);color:white;border:none;border-radius:8px;padding:7px 16px;cursor:pointer;font-size:13px;"><i class="fas fa-stop"></i> Stop</button>
|
||||
<div id="enc-recording-indicator" class="recording-indicator hidden"><div class="pulse-dot"></div><span>Recording... <span id="enc-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-file-lines"></i> Transcript</h3><button id="enc-clear" class="btn-sm btn-ghost"><i class="fas fa-eraser"></i> Clear</button></div>
|
||||
<div id="enc-transcript" class="editable-box editable-box-large" contenteditable="true" data-placeholder="Transcript appears here or type/paste directly..."></div>
|
||||
</div>
|
||||
|
||||
<button id="enc-generate-btn" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate HPI</button>
|
||||
|
||||
<div id="enc-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Generated HPI</h3>
|
||||
<div class="output-actions">
|
||||
<span id="enc-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="enc-hpi-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="enc-hpi-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="enc-hpi-text" data-label="hpi-encounter"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="enc-hpi-text" class="output-text output-text-large" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<textarea id="enc-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify (e.g., 'make it shorter', 'add that patient has asthma history')"></textarea>
|
||||
<button class="btn-sm btn-primary" id="enc-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="enc-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-phone-volume"></i> Pagers & Extensions</h2>
|
||||
<p>Your personal directory of hospital phone extensions and pagers — searchable, edit-friendly, soft-delete</p>
|
||||
</div>
|
||||
|
||||
<div class="card ext-toolbar">
|
||||
<div style="display:flex;gap:10px;align-items:center;padding:12px 16px;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:220px;position:relative;">
|
||||
<i class="fas fa-search" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--g400);font-size:13px;"></i>
|
||||
<input type="search" id="ext-search" placeholder="Search by location, name, number..." autocomplete="off"
|
||||
style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;">
|
||||
</div>
|
||||
<button id="ext-add-btn" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add</button>
|
||||
<button id="ext-trash-btn" class="btn-sm btn-ghost"><i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span></button>
|
||||
</div>
|
||||
|
||||
<div id="ext-form-wrap" class="hidden" style="border-top:1px solid var(--g100);padding:14px 16px;background:var(--g50);">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">
|
||||
<div class="demo-field">
|
||||
<label>Location</label>
|
||||
<input type="text" id="ext-location" list="ext-location-list" placeholder="e.g., Main Hospital" maxlength="120">
|
||||
<datalist id="ext-location-list"></datalist>
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Name / Department</label>
|
||||
<input type="text" id="ext-name" placeholder="e.g., Nursery, 5B, On-call" maxlength="120">
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Number</label>
|
||||
<input type="text" id="ext-number" placeholder="e.g., 5866" maxlength="40">
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Type</label>
|
||||
<select id="ext-type">
|
||||
<option value="extension">Extension</option>
|
||||
<option value="pager">Pager</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<div class="demo-field">
|
||||
<label>Notes (optional)</label>
|
||||
<input type="text" id="ext-notes" placeholder="e.g., After hours: 5867" maxlength="500">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;display:flex;gap:6px;">
|
||||
<button id="ext-save-btn" class="btn-sm btn-primary"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="ext-cancel-btn" class="btn-sm btn-ghost">Cancel</button>
|
||||
<span id="ext-form-status" style="font-size:12px;margin-left:6px;color:var(--g500);align-self:center;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ext-mode-banner" class="hidden" style="margin:10px 0;padding:8px 12px;background:#fef3c7;border-left:4px solid #f59e0b;border-radius:6px;font-size:13px;color:#78350f;">
|
||||
<i class="fas fa-trash-can"></i> Viewing trash. <button id="ext-back-active" class="btn-sm btn-ghost" style="margin-left:8px;">Back to active</button>
|
||||
</div>
|
||||
|
||||
<div id="ext-list" style="margin-top:10px;">
|
||||
<p style="text-align:center;color:#9ca3af;padding:40px;">Loading...</p>
|
||||
</div>
|
||||
|
|
@ -1,361 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-circle-question"></i> Frequently Asked Questions</h2>
|
||||
<p>Learn how Pediatric AI Scribe works and get the most out of it</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-container">
|
||||
|
||||
<!-- Getting Started -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-rocket"></i> Getting Started</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What is Pediatric AI Scribe?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Pediatric AI Scribe is an AI-powered clinical documentation tool designed specifically for pediatric medicine. It helps physicians generate structured clinical notes from voice recordings or typed text, saving time on documentation so you can focus on patient care.</p>
|
||||
<p>It supports HPIs, SOAP notes, hospital courses, chart reviews, well visits, sick visits, developmental milestone assessments, and more.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">How do I create my first note?</button>
|
||||
<div class="faq-answer">
|
||||
<p>The easiest way to start is with <strong>Live Encounter</strong>:</p>
|
||||
<ol>
|
||||
<li>Go to the <strong>Encounter</strong> tab</li>
|
||||
<li>Enter the patient's age and gender</li>
|
||||
<li>Click <strong>Start Recording</strong> and speak naturally during your patient encounter</li>
|
||||
<li>Click <strong>Stop</strong> when done — the audio is transcribed automatically</li>
|
||||
<li>Click <strong>Generate HPI</strong> to create a structured note</li>
|
||||
<li>Edit the note as needed, then <strong>Copy</strong> to paste into your EHR</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">Can I type or paste notes instead of recording?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Yes. Every transcript box is editable. You can type directly, paste from another source, or combine typed text with a recording. The AI works with whatever text is in the transcript area when you click Generate.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What types of notes can I generate?</button>
|
||||
<div class="faq-answer">
|
||||
<ul>
|
||||
<li><strong>HPI</strong> — from live encounters or dictation, with OLDCARTS structure</li>
|
||||
<li><strong>SOAP Notes</strong> — full SOAP or subjective-only from dictation</li>
|
||||
<li><strong>Hospital Course</strong> — from progress notes, in prose, day-by-day, or organ-system format</li>
|
||||
<li><strong>Chart Review</strong> — summarize outpatient, subspecialty, or ED visits for precharting</li>
|
||||
<li><strong>Well Visit</strong> — complete preventive care notes with SSHADESS, milestones, and vaccines</li>
|
||||
<li><strong>Sick Visit</strong> — quick documentation with auto-suggested ROS and PE</li>
|
||||
<li><strong>Milestone Assessment</strong> — developmental narrative from selected milestones</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI & Models -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-robot"></i> AI & Models</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What AI model should I use?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Each tab has a model selector dropdown. All available models have been tested and configured by your administrator for clinical documentation quality. They are routed through HIPAA-compliant providers with signed Business Associate Agreements (BAAs).</p>
|
||||
<p>All models are capable of generating accurate clinical notes. If you are unsure which to pick, start with the default. You can experiment with different models and see which output style you prefer — some may be faster, some more detailed, some more concise. You can choose a different model per tab depending on the task.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">Does the AI learn from my edits?</button>
|
||||
<div class="faq-answer">
|
||||
<p><strong>Yes.</strong> The app uses a correction tracking system inspired by Dragon Medical's adaptive learning. Here is how it works:</p>
|
||||
<ol>
|
||||
<li>When the AI generates a note, the original output is stored in memory</li>
|
||||
<li>You edit the note to match your preferred style — fix phrasing, add details, restructure sections</li>
|
||||
<li>When you click <strong>Save</strong>, the app detects what you changed and stores the correction</li>
|
||||
<li>On future notes, your past corrections are included as style hints so the AI adapts to your documentation preferences</li>
|
||||
</ol>
|
||||
<p>The more you use the app and save your edits, the better the AI gets at matching your style. You can view and manage your stored corrections in <strong>Settings > AI Corrections</strong>.</p>
|
||||
<p><em>Note: Corrections are applied as gentle suggestions, not strict rules. The AI prioritizes clinical accuracy over style matching.</em></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">Can I customize the AI's prompts?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Administrators can edit all AI prompts from the <strong>Admin Panel > Settings > Prompts</strong> section. This lets you adjust the instructions the AI follows for each note type without changing any code. Changes take effect immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What does the "Refine" button do?</button>
|
||||
<div class="faq-answer">
|
||||
<p>After generating a note, you can give the AI plain-language instructions to modify it. For example:</p>
|
||||
<ul>
|
||||
<li>"Make it shorter"</li>
|
||||
<li>"Add that the patient has a history of asthma"</li>
|
||||
<li>"Summarize the labs"</li>
|
||||
<li>"Change the assessment to include bronchiolitis"</li>
|
||||
</ul>
|
||||
<p>The AI references both its current output and your original source material (transcript, pasted notes, labs) when refining, so it can look up details from the original input.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice & Transcription -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-microphone"></i> Voice & Transcription</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">How does voice transcription work?</button>
|
||||
<div class="faq-answer">
|
||||
<p>When you stop recording, the audio is sent to a speech-to-text service that converts it to text. The app supports multiple transcription providers including Whisper, Deepgram, and Google Gemini. Your administrator configures which provider is used.</p>
|
||||
<p>You will see a blue status bar at the top while transcription is in progress. You can continue working on the page while it processes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What is Browser Whisper?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Browser Whisper runs the Whisper AI model entirely in your browser using WebAssembly. Your audio never leaves your device, making it the most private transcription option. You can enable it in <strong>Settings > Browser Whisper</strong>.</p>
|
||||
<p>It works offline and is HIPAA-safe since no data is transmitted. The tradeoff is that it is slower than cloud-based transcription and requires downloading the model (~40–240 MB) on first use.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">Can I use the app on my phone?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Yes. The app is a Progressive Web App (PWA) that works in any modern browser. On mobile:</p>
|
||||
<ul>
|
||||
<li>Open the app in Chrome or Safari</li>
|
||||
<li>Tap "Add to Home Screen" to install it as a standalone app</li>
|
||||
<li>Recording works in the foreground, but <strong>audio stops if you lock the screen or switch apps</strong> on iOS and most Android devices — this is a browser limitation, not specific to this app</li>
|
||||
</ul>
|
||||
<p>On desktop, recording continues normally when the browser is minimized or in the background.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What happens if transcription fails?</button>
|
||||
<div class="faq-answer">
|
||||
<p>If transcription fails, your audio is automatically backed up to the server for 24 hours. You can retry transcription from <strong>Settings > Audio Backups</strong>. If browser speech recognition was active during recording, the live transcript is preserved as a fallback.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">Can the AI read my notes aloud?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Yes. Click the <strong>Read</strong> button on any generated note to hear it spoken aloud. This uses text-to-speech (TTS) powered by Google, OpenAI, or ElevenLabs depending on your setup. You can choose your preferred voice in <strong>Settings > Voice Preferences</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saving & Export -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-floppy-disk"></i> Saving & Export</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">Are my encounters saved?</button>
|
||||
<div class="faq-answer">
|
||||
<p>You can save encounters using the <strong>Save</strong> button at the top of each tab. Saved encounters include the transcript, generated note, and patient label. You can reload them later using the <strong>Load</strong> button.</p>
|
||||
<p>Saved encounters are automatically deleted after 7 days (configurable by your administrator). This is intentional — the app is a documentation tool, not a medical record system. Copy your final notes to your EHR for permanent storage.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">How do I export notes?</button>
|
||||
<div class="faq-answer">
|
||||
<ul>
|
||||
<li><strong>Copy</strong> — one-click copy to clipboard, ready to paste into any EHR</li>
|
||||
<li><strong>Nextcloud</strong> — export directly to your Nextcloud instance (configure in Settings)</li>
|
||||
<li><strong>Documents</strong> — upload files to S3-compatible storage from Settings</li>
|
||||
</ul>
|
||||
<p>All generated text is plain text with no markdown formatting, designed to paste cleanly into any EHR system.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy & Security -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-shield-halved"></i> Privacy & Security</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">Is my patient data safe?</button>
|
||||
<div class="faq-answer">
|
||||
<p>The app is designed with clinical privacy in mind:</p>
|
||||
<ul>
|
||||
<li>All connections use HTTPS/TLS encryption</li>
|
||||
<li>Audio and encounter data are temporary — auto-deleted within hours or days</li>
|
||||
<li>No patient data is stored long-term on the server</li>
|
||||
<li>Every action is audit-logged (who accessed what, when)</li>
|
||||
<li>Two-factor authentication (2FA) and session management are available</li>
|
||||
<li>Browser Whisper keeps audio entirely on your device</li>
|
||||
</ul>
|
||||
<p>For HIPAA compliance, ensure your administrator has configured a BAA-covered AI provider (such as AWS Bedrock, Google Vertex AI, or Azure OpenAI).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What is two-factor authentication (2FA)?</button>
|
||||
<div class="faq-answer">
|
||||
<p>2FA adds an extra layer of security to your account. After entering your password, you also enter a 6-digit code from an authenticator app (like Google Authenticator or Authy). Enable it in <strong>Settings > Two-Factor Authentication</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">How do I manage my active sessions?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Go to <strong>Settings > Active Sessions</strong> to see all devices where you are logged in. You can revoke any session individually or click <strong>Revoke All Other Sessions</strong> to log out every other device. Your current session is highlighted and cannot be revoked from this screen — use the Logout button instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What happens when I change my password?</button>
|
||||
<div class="faq-answer">
|
||||
<p>When you change your password in <strong>Settings > Change Password</strong>, all other active sessions are automatically logged out for security. Only your current session remains active. The app also checks if your new password has appeared in known data breaches and warns you (but does not prevent you from using it).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Well Visit & Sick Visit -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-baby"></i> Well Visit & Sick Visit</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">How does the Well Visit tab work?</button>
|
||||
<div class="faq-answer">
|
||||
<p>The Well Visit tab follows the AAP Bright Futures periodicity schedule. It includes:</p>
|
||||
<ul>
|
||||
<li><strong>Visit by Age</strong> — recommended screenings, vaccines, and anticipatory guidance for each visit age</li>
|
||||
<li><strong>Milestones</strong> — developmental milestone tracker from birth through 11 years across multiple domains</li>
|
||||
<li><strong>SSHADESS</strong> — adolescent psychosocial assessment (Strengths, School, Home, Activities, Drugs, Emotions, Sexuality, Safety) for ages 12+</li>
|
||||
<li><strong>Visit Note</strong> — generates a complete well visit note combining ROS, PE, milestones, and SSHADESS data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What do the WNL / Abnormal / Not Reviewed buttons do?</button>
|
||||
<div class="faq-answer">
|
||||
<p>In the ROS and Physical Exam sections, each system has three options:</p>
|
||||
<ul>
|
||||
<li><strong>WNL / Normal</strong> — within normal limits, no concerns</li>
|
||||
<li><strong>Abnormal</strong> — a text box appears so you can describe the finding</li>
|
||||
<li><strong>Not Reviewed / Not Examined</strong> — explicitly not assessed</li>
|
||||
</ul>
|
||||
<p>Use <strong>All WNL</strong> to quickly mark everything normal, then click individual systems to change specific ones. Use <strong>Clear</strong> to reset all selections.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Learning Hub -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-graduation-cap"></i> Learning Hub</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What is the Learning Hub?</button>
|
||||
<div class="faq-answer">
|
||||
<p>The Learning Hub is an educational platform integrated into the app. It contains articles, clinical pearls, quizzes, and slide presentations created by moderators and administrators. You can browse by category, search content, and take quizzes to test your knowledge.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">How do quizzes work?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Quizzes include multiple-choice, multi-select, and true/false questions. After submitting your answers, you see your score along with explanations for each question. Your past attempts and scores are tracked so you can monitor your progress over time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">Can I create Learning Hub content?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Moderators and administrators can create content using the <strong>CMS</strong> tab. You can write articles manually, or use AI to generate content from a topic description, uploaded PDFs, or files from Nextcloud. The CMS also supports Marp-based slide presentations with PPTX export.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calculators -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-calculator"></i> Pediatric Calculators</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What calculators are available?</button>
|
||||
<div class="faq-answer">
|
||||
<p>The <strong>Calculators</strong> tab includes clinical tools commonly used in pediatric practice:</p>
|
||||
<ul>
|
||||
<li><strong>Blood Pressure Percentile</strong> — AAP 2017 guidelines using the Rosner quantile spline regression method. Requires age, sex, height, and BP. Provides exact systolic and diastolic percentiles adjusted for height, with AAP classification (Normal, Elevated, Stage 1, Stage 2). Includes definitions of hypertension and hypotension.</li>
|
||||
<li><strong>BMI Percentile</strong> — CDC 2000 growth reference with extended obesity classification (Class 1, 2, 3 using % of 95th percentile). Shows BMI chart with percentile curves.</li>
|
||||
<li><strong>Growth Charts</strong> — Visual percentile curves (3rd through 97th) with your patient plotted. Includes Weight-for-Age, Length/Height-for-Age (with mid-parental height), Head Circumference, Weight-for-Length, and Fenton preterm charts.</li>
|
||||
<li><strong>Bilirubin</strong> — AAP 2022 phototherapy threshold calculator and Bhutani hour-specific nomogram with risk zone classification. Includes Nelson Table 137.1 risk factors.</li>
|
||||
<li><strong>Vital Signs by Age</strong> — Harriet Lane reference table for HR, RR, BP, and weight by age from preterm through 18 years. Includes quick formulas for estimated weight, minimum SBP, ETT size, and maintenance fluids.</li>
|
||||
<li><strong>Body Surface Area</strong> — Mosteller formula for BSA calculation.</li>
|
||||
<li><strong>Weight-Based Dosing</strong> — Dose per kg with frequency, max dose cap, and volume calculation from concentration.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">How accurate is the BP calculator?</button>
|
||||
<div class="faq-answer">
|
||||
<p>The BP calculator uses the same Rosner quantile spline regression method as the Baylor College of Medicine reference calculator. It computes exact percentiles (1st-99th) based on your patient's age, sex, and height using published regression coefficients. This is the same methodology underlying the AAP 2017 normative tables. Results are height-adjusted and clinically accurate.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">What are the growth chart curves?</button>
|
||||
<div class="faq-answer">
|
||||
<p>The growth charts display WHO/CDC percentile curves (3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th percentiles) with your patient's measurement plotted as a blue dot. The 50th percentile is shown as a bold green line. Shaded bands show the normal range between symmetric percentiles.</p>
|
||||
<p>For <strong>Length/Height-for-Age</strong>, you can optionally enter both parents' heights to see the mid-parental target height range plotted on the chart.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Troubleshooting -->
|
||||
<div class="faq-section">
|
||||
<h3 class="faq-section-title"><i class="fas fa-wrench"></i> Troubleshooting</h3>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">The AI generated an incorrect note. What should I do?</button>
|
||||
<div class="faq-answer">
|
||||
<p>AI-generated notes should always be reviewed before use. If the output is incorrect:</p>
|
||||
<ol>
|
||||
<li>Edit the note directly in the output area — it is fully editable</li>
|
||||
<li>Use the <strong>Refine</strong> button with specific instructions (e.g., "remove the fever mention" or "change the diagnosis")</li>
|
||||
<li>Try a different AI model using the model selector — Premium models may produce more accurate results</li>
|
||||
<li><strong>Save</strong> your corrected version — the app learns from your edits and will improve over time</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">My recording did not transcribe. What happened?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Common causes:</p>
|
||||
<ul>
|
||||
<li><strong>Microphone permission denied</strong> — check your browser's site permissions</li>
|
||||
<li><strong>Recording too short</strong> — very short recordings (under 1 second) may not contain enough audio</li>
|
||||
<li><strong>Screen was locked (mobile)</strong> — iOS and Android stop recording when the screen locks. Keep the screen on while recording.</li>
|
||||
<li><strong>Network issue</strong> — cloud transcription requires an internet connection</li>
|
||||
</ul>
|
||||
<p>If transcription fails, your audio is automatically backed up. Check <strong>Settings > Audio Backups</strong> to retry.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<button class="faq-question">I cannot see all the features. Why?</button>
|
||||
<div class="faq-answer">
|
||||
<p>Some features are role-restricted:</p>
|
||||
<ul>
|
||||
<li><strong>Admin Panel</strong> — visible only to administrators</li>
|
||||
<li><strong>CMS (Content Management)</strong> — visible to moderators and administrators</li>
|
||||
<li><strong>Registration</strong> — may be disabled by your administrator</li>
|
||||
</ul>
|
||||
<p>Contact your administrator if you need access to a restricted feature.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-hospital"></i> Hospital Course Generator</h2>
|
||||
<p>Upload notes by date → AI generates organized hospital course</p>
|
||||
</div>
|
||||
|
||||
<div class="demographics-bar">
|
||||
<div class="demo-field"><label>Patient Age</label><input type="text" id="hc-age" placeholder="e.g., 3 years"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="hc-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
|
||||
<div class="demo-field"><label>PMH</label><input type="text" id="hc-pmh" placeholder="e.g., asthma, obesity"></div>
|
||||
<div class="demo-field">
|
||||
<label>Unit</label>
|
||||
<select id="hc-setting">
|
||||
<option value="floor">General Floor</option>
|
||||
<option value="picu">PICU</option>
|
||||
<option value="nicu">NICU</option>
|
||||
<option value="psych">Psych/Behavioral</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field"><label>LOS (days)</label><input type="number" id="hc-los" placeholder="e.g., 3" min="1"></div>
|
||||
<div class="demo-field">
|
||||
<label>Format</label>
|
||||
<select id="hc-format">
|
||||
<option value="auto">Auto (AI decides)</option>
|
||||
<option value="prose">Prose Summary</option>
|
||||
<option value="dayByDay">Day by Day</option>
|
||||
<option value="organSystem">Organ System (ICU)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar-wrap">
|
||||
<div class="save-bar" id="hosp-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="hosp-label" class="save-label-input" placeholder="Patient label (e.g. John D, Visit #42)">
|
||||
</div>
|
||||
<button id="btn-hosp-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-hosp-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
<button id="btn-hosp-new" class="btn-sm btn-ghost" title="Clear all and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
|
||||
</div>
|
||||
<div id="hosp-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="hosp-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="hosp-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ED Note -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-truck-medical"></i> ED Note (before admission)</h3></div>
|
||||
<div class="note-entry">
|
||||
<div class="note-meta">
|
||||
<input type="date" id="hc-ed-date">
|
||||
<input type="text" id="hc-ed-labs" placeholder="ED Labs (e.g., WBC 15, BMP normal...)">
|
||||
</div>
|
||||
<div id="hc-ed-content" class="editable-box" contenteditable="true" data-placeholder="Paste or type ED note here..."></div>
|
||||
<div class="note-dictate">
|
||||
<button class="btn-sm btn-ghost hc-dictate-btn" data-target="hc-ed-content"><i class="fas fa-microphone"></i> Dictate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- H&P -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-file-medical"></i> H&P (Admission Note)</h3></div>
|
||||
<div class="note-entry">
|
||||
<div class="note-meta">
|
||||
<input type="date" id="hc-hp-date">
|
||||
</div>
|
||||
<div id="hc-hp-content" class="editable-box" contenteditable="true" data-placeholder="Paste or type H&P here..."></div>
|
||||
<div class="note-dictate">
|
||||
<button class="btn-sm btn-ghost hc-dictate-btn" data-target="hc-hp-content"><i class="fas fa-microphone"></i> Dictate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Notes (dynamic) -->
|
||||
<div id="hc-notes-container">
|
||||
<div class="card note-card" data-note-index="0">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-notes-medical"></i> Progress Note #1</h3>
|
||||
<button class="btn-sm btn-ghost remove-note-btn"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="note-entry">
|
||||
<div class="note-meta">
|
||||
<input type="date" class="note-date">
|
||||
<select class="note-type">
|
||||
<option value="progress-attending">Progress - Attending</option>
|
||||
<option value="progress-resident">Progress - Resident</option>
|
||||
<option value="progress-np">Progress - NP/PA</option>
|
||||
<option value="consult">Consult Note</option>
|
||||
<option value="procedure">Procedure Note</option>
|
||||
<option value="discharge-summary">Discharge Summary</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="editable-box note-content" contenteditable="true" data-placeholder="Paste or type note..."></div>
|
||||
<div class="note-dictate">
|
||||
<button class="btn-sm btn-ghost hc-dictate-btn" data-target-class="note-content"><i class="fas fa-microphone"></i> Dictate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="hc-add-note" class="btn-sm btn-ghost" style="margin:8px 0"><i class="fas fa-plus"></i> Add Progress Note</button>
|
||||
|
||||
<!-- Labs -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-flask"></i> Labs</h3><button id="hc-add-lab" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add</button></div>
|
||||
<div id="hc-labs-container">
|
||||
<div class="lab-entry">
|
||||
<input type="date" class="lab-date">
|
||||
<input type="text" class="lab-values" placeholder="e.g., WBC 12.5, H/H 10.2/31, BMP: Na 138, K 3.5, BUN 11, Cr 0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Instructions -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Additional Instructions (optional)</h3></div>
|
||||
<textarea id="hc-instructions" class="full-input" rows="3" placeholder="e.g., 'Focus on respiratory course', 'Patient was transferred from outside hospital'"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button id="hc-generate-btn" class="btn-generate btn-generate-blue"><i class="fas fa-wand-magic-sparkles"></i> Generate Hospital Course</button>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div id="hc-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Hospital Course</h3>
|
||||
<div class="output-actions">
|
||||
<span id="hc-format-tag" class="model-tag"></span>
|
||||
<span id="hc-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="hc-course-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="hc-course-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="hc-course-text" data-label="hospital-course"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hc-course-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<textarea id="hc-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify or add discharge day info..."></textarea>
|
||||
<button class="btn-sm btn-primary" id="hc-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="hc-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
<button class="btn-sm btn-warning" id="hc-clarify-btn"><i class="fas fa-circle-question"></i> What's Missing?</button>
|
||||
</div>
|
||||
<div id="hc-clarify-output" class="clarify-box hidden"></div>
|
||||
</div>
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-graduation-cap"></i> Learning Hub</h2>
|
||||
<p>Pediatric education, clinical pearls, and self-assessment quizzes</p>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="card" style="padding:12px 16px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<i class="fas fa-search" style="color:var(--g400);"></i>
|
||||
<input type="text" id="lh-search" class="full-input" placeholder="Search topics, subjects..." style="border:none;flex:1;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category pills -->
|
||||
<div id="lh-categories" class="lh-category-bar"></div>
|
||||
|
||||
<!-- Content feed -->
|
||||
<div id="lh-feed" class="lh-feed"></div>
|
||||
|
||||
<!-- Content viewer (hidden until item clicked) -->
|
||||
<div id="lh-viewer" class="lh-viewer hidden">
|
||||
<button id="lh-back" class="btn-sm btn-ghost" style="margin-bottom:12px;"><i class="fas fa-arrow-left"></i> Back to Feed</button>
|
||||
<!-- Presentation viewer — shown instead of body card for slide content -->
|
||||
<div id="lh-presentation-viewer" class="hidden" style="margin-bottom:12px;"></div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 id="lh-viewer-title"></h3>
|
||||
<span id="lh-viewer-meta" style="font-size:12px;color:var(--g500);"></span>
|
||||
</div>
|
||||
<div id="lh-viewer-body" class="lh-content-body" style="padding:16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Quiz section (if content has questions) -->
|
||||
<div id="lh-quiz-section" class="hidden" style="margin-top:16px;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-question"></i> Quiz</h3>
|
||||
<span id="lh-quiz-count" style="font-size:12px;color:var(--g500);"></span>
|
||||
</div>
|
||||
<div id="lh-quiz-questions" style="padding:16px;"></div>
|
||||
<div style="padding:0 16px 16px;">
|
||||
<button id="lh-submit-quiz" class="btn-generate"><i class="fas fa-check"></i> Submit Answers</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results -->
|
||||
<div id="lh-quiz-results" class="hidden" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-chart-bar"></i> Results</h3>
|
||||
<span id="lh-quiz-score" class="model-tag"></span>
|
||||
</div>
|
||||
<div id="lh-quiz-explanations" style="padding:16px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Past attempts -->
|
||||
<div id="lh-progress-section" class="hidden" style="margin-top:12px;">
|
||||
<div class="card" style="padding:12px 16px;">
|
||||
<h4 style="margin:0 0 8px;font-size:13px;color:var(--g600);"><i class="fas fa-history"></i> Your Past Attempts</h4>
|
||||
<div id="lh-progress-list" style="font-size:13px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-stethoscope"></i> Physical Exam Guide</h2>
|
||||
<p>OSCE-style reference for age-appropriate MSK and Neuro exam + narrative report generator</p>
|
||||
</div>
|
||||
|
||||
<div class="demographics-bar">
|
||||
<div class="demo-field">
|
||||
<label>Age Group</label>
|
||||
<select id="pe-age-group">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="newborn">Newborn (0–28 days)</option>
|
||||
<option value="infant">Infant (1–12 months)</option>
|
||||
<option value="toddler">Toddler (1–3 years)</option>
|
||||
<option value="preschool">Preschool (3–5 years)</option>
|
||||
<option value="school">School-age (6–11 years)</option>
|
||||
<option value="adolescent">Adolescent (12–21 years)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Patient Age</label>
|
||||
<input type="text" id="pe-age" placeholder="e.g., 4 months">
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Gender</label>
|
||||
<select id="pe-gender">
|
||||
<option value="">Select</option>
|
||||
<option>Male</option>
|
||||
<option>Female</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Report Style</label>
|
||||
<select id="pe-format">
|
||||
<option value="narrative">Narrative (paragraphs)</option>
|
||||
<option value="list">Structured List</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wv-subtab-bar">
|
||||
<button class="wv-subtab-btn active" data-pesystem="msk">
|
||||
<i class="fas fa-bone"></i> Musculoskeletal
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-pesystem="neuro">
|
||||
<i class="fas fa-brain"></i> Neurologic
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-pesystem="resp">
|
||||
<i class="fas fa-lungs"></i> Respiratory
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-pesystem="cv">
|
||||
<i class="fas fa-heart-pulse"></i> Cardiovascular
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="pe-content">
|
||||
<p style="text-align:center;color:#9ca3af;padding:40px;">Select an age group above to see the exam guide.</p>
|
||||
</div>
|
||||
|
||||
<div class="milestone-actions" id="pe-actions" style="display:none">
|
||||
<button id="pe-all-normal" class="btn-sm btn-success"><i class="fas fa-check-double"></i> All Normal</button>
|
||||
<button id="pe-all-clear" class="btn-sm btn-ghost"><i class="fas fa-eraser"></i> Clear All</button>
|
||||
<button id="pe-generate-btn" class="btn-generate btn-generate-teal"><i class="fas fa-wand-magic-sparkles"></i> Generate Exam Report</button>
|
||||
</div>
|
||||
|
||||
<div id="pe-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Physical Exam Report</h3>
|
||||
<div class="output-actions">
|
||||
<span id="pe-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="pe-narrative-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="pe-narrative-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="pe-narrative-text" data-label="physical-exam"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pe-summary-bar" class="summary-bar hidden"></div>
|
||||
<div id="pe-narrative-text" class="output-text" contenteditable="true"></div>
|
||||
</div>
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-cog"></i> Settings</h2>
|
||||
<p>Account security, integrations, and personal templates</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-page">
|
||||
|
||||
<!-- Voice Preferences (STT & TTS) -->
|
||||
<div class="settings-section card" id="voice-preferences-section">
|
||||
<h3><i class="fas fa-microphone-lines"></i> Voice Preferences</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Customize your speech-to-text model and text-to-speech voice. These settings apply to all your recording and read-aloud features.</p>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;color:var(--g700);margin-bottom:6px;">Speech-to-Text Model (Transcription)</label>
|
||||
<p style="font-size:12px;color:var(--g500);margin:4px 0 8px;">Choose the AI model for transcribing your audio recordings. More accurate models may be slower.</p>
|
||||
<select id="stt-model-select" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;width:100%;max-width:400px;">
|
||||
<option value="">Server default</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;color:var(--g700);margin-bottom:6px;">Text-to-Speech Voice (Read Aloud)</label>
|
||||
<p style="font-size:12px;color:var(--g500);margin:4px 0 8px;">Choose the voice for the "Read Aloud" feature. Preview available after selection.</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<select id="tts-voice-select" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:400px;">
|
||||
<option value="">Server default</option>
|
||||
</select>
|
||||
<button id="btn-preview-voice" class="btn-sm btn-ghost"><i class="fas fa-play"></i> Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;">
|
||||
<button id="btn-save-voice-prefs" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save Voice Preferences</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser Whisper -->
|
||||
<div class="settings-section card" id="browser-whisper-section">
|
||||
<h3><i class="fas fa-microchip"></i> Browser Transcription (Local Whisper)</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Transcribes audio entirely in your browser — no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally.</p>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<label style="font-size:13px;font-weight:600;">Enable browser transcription:</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="browser-whisper-enabled" style="accent-color:var(--blue);width:16px;height:16px;">
|
||||
<span style="font-size:13px;" id="browser-whisper-status">Off</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="browser-whisper-model-row" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<label style="font-size:13px;font-weight:600;">Model:</label>
|
||||
<select id="browser-whisper-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="Xenova/whisper-tiny.en">Tiny (~39MB) — fastest, ~2-3s</option>
|
||||
<option value="Xenova/whisper-base.en">Base (~74MB) — balanced, ~3-5s</option>
|
||||
<option value="Xenova/whisper-small.en">Small (~244MB) — best quality, ~6-10s</option>
|
||||
</select>
|
||||
<button id="btn-whisper-preload" class="btn-sm btn-ghost"><i class="fas fa-download"></i> Pre-download model</button>
|
||||
</div>
|
||||
<div id="browser-whisper-progress" style="display:none;font-size:12px;color:var(--g500);margin-top:4px;">
|
||||
<i class="fas fa-spinner fa-spin"></i> <span id="browser-whisper-progress-text">Loading...</span>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> When enabled, overrides server transcription. Falls back to server if browser transcription fails.</p>
|
||||
<p style="font-size:11px;color:var(--orange);margin:4px 0 0;display:none;" id="browser-whisper-csp-warning"><i class="fas fa-exclamation-triangle"></i> <strong>Network/Firewall Issue:</strong> If model download fails, check that <code>cdn.jsdelivr.net</code> and <code>huggingface.co</code> are not blocked. Server transcription will be used as fallback.</p>
|
||||
</div>
|
||||
|
||||
<!-- Web Speech Recognition (Real-time Streaming) -->
|
||||
<div class="settings-section card" id="web-speech-section" style="border-left:3px solid var(--orange);">
|
||||
<h3><i class="fas fa-wave-square"></i> Real-Time Streaming Transcription</h3>
|
||||
<div style="background:var(--orange-light);padding:12px;border-radius:6px;margin-bottom:12px;">
|
||||
<p style="font-size:13px;color:var(--orange-dark);margin:0;"><i class="fas fa-exclamation-triangle"></i> <strong>Privacy Warning:</strong> Uses your browser's built-in speech recognition, which <strong>may send audio to cloud servers</strong> (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.</p>
|
||||
</div>
|
||||
<p style="font-size:13px;color:var(--g600);">See words appear as you speak (streaming). Overrides browser and server transcription when enabled. <strong>Not HIPAA-compliant</strong> in most browsers.</p>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<label style="font-size:13px;font-weight:600;">Enable real-time streaming:</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="web-speech-enabled" style="accent-color:var(--orange);width:16px;height:16px;">
|
||||
<span style="font-size:13px;" id="web-speech-status">Off</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="web-speech-privacy-info" style="font-size:12px;color:var(--g500);padding:10px;background:var(--g50);border-radius:6px;margin-top:8px;">
|
||||
<p style="margin:0 0 4px;font-weight:600;">Current Browser:</p>
|
||||
<p style="margin:0;" id="web-speech-browser-info">Detecting...</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use Browser Whisper (offline batch mode) or Server transcription with HIPAA-eligible provider.</p>
|
||||
</div>
|
||||
|
||||
<!-- Change Password — hidden by default; unhidden only for users with a real password -->
|
||||
<div class="settings-section card" id="change-password-section" style="display:none;">
|
||||
<h3><i class="fas fa-key"></i> Change Password</h3>
|
||||
<div class="form-group" style="max-width:340px;">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="pw-current" placeholder="Enter current password">
|
||||
</div>
|
||||
<div class="form-group" style="max-width:340px;">
|
||||
<label>New Password (8+ characters)</label>
|
||||
<input type="password" id="pw-new" minlength="8" placeholder="Enter new password">
|
||||
</div>
|
||||
<div class="form-group" style="max-width:340px;">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" id="pw-confirm" minlength="8" placeholder="Confirm new password">
|
||||
</div>
|
||||
<button id="btn-change-password" class="btn-sm btn-primary"><i class="fas fa-check"></i> Change Password</button>
|
||||
<span id="pw-change-status" style="font-size:13px;margin-left:8px;"></span>
|
||||
</div>
|
||||
|
||||
<!-- 2FA — hidden by default; unhidden only for users with a real password (SSO users use their IdP's MFA) -->
|
||||
<div class="settings-section card" id="2fa-section" style="display:none;">
|
||||
<h3><i class="fas fa-shield-halved"></i> Two-Factor Authentication</h3>
|
||||
<p id="2fa-status">Status: Loading...</p>
|
||||
<button id="btn-setup-2fa" class="btn-sm btn-primary">Enable 2FA</button>
|
||||
<button id="btn-disable-2fa" class="btn-sm btn-ghost hidden" style="color:var(--red);">Disable 2FA</button>
|
||||
<div id="2fa-backup-info"></div>
|
||||
<div id="2fa-disable-confirm" class="hidden" style="margin-top:10px;padding:12px;background:var(--g50);border-radius:8px;border:1px solid var(--g200);max-width:340px;">
|
||||
<div class="form-group" style="margin-bottom:8px;">
|
||||
<label>Enter your password to confirm:</label>
|
||||
<input type="password" id="2fa-disable-password" placeholder="Password">
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button id="btn-disable-2fa-confirm" class="btn-sm btn-primary" style="background:var(--red);border-color:var(--red);">Confirm Disable</button>
|
||||
<button id="btn-disable-2fa-cancel" class="btn-sm btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="2fa-setup" class="hidden">
|
||||
<p>Scan this QR code with your authenticator app:</p>
|
||||
<img id="2fa-qr" src="" alt="QR Code">
|
||||
<p>Or enter manually: <code id="2fa-secret"></code></p>
|
||||
<div class="form-group">
|
||||
<label>Enter the 6-digit code to verify:</label>
|
||||
<input type="text" id="2fa-verify-code" maxlength="6" placeholder="123456">
|
||||
<button id="btn-verify-2fa" class="btn-sm btn-success">Verify & Enable</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions — hidden for SSO-only users (revoke can't stick against an active IdP session) -->
|
||||
<div class="settings-section card" id="sessions-section" style="display:none;">
|
||||
<h3><i class="fas fa-desktop"></i> Active Sessions</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Devices where you are currently logged in. Revoke any session to immediately log that device out.</p>
|
||||
<div style="margin-bottom:10px;">
|
||||
<button id="btn-revoke-all-sessions" class="btn-sm btn-ghost" style="color:var(--red);font-size:12px;"><i class="fas fa-ban"></i> Revoke All Other Sessions</button>
|
||||
</div>
|
||||
<div id="sessions-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading sessions...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-cloud"></i> Nextcloud Integration</h3>
|
||||
<p>Export generated documents to your Nextcloud.</p>
|
||||
<div id="nc-status">Not connected</div>
|
||||
<div class="form-group">
|
||||
<label>Nextcloud URL</label>
|
||||
<input type="text" id="nc-url" placeholder="https://cloud.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="nc-user" placeholder="your-username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>App Password</label>
|
||||
<input type="password" id="nc-pass" placeholder="Generate in Nextcloud → Settings → Security">
|
||||
<small>Go to Nextcloud → Settings → Security → Create new app password</small>
|
||||
</div>
|
||||
<button id="btn-nc-connect" class="btn-sm btn-primary">Connect</button>
|
||||
<button id="btn-nc-disconnect" class="btn-sm btn-ghost hidden">Disconnect</button>
|
||||
<div id="nc-webdav-path-section" class="hidden" style="margin-top:14px;padding-top:14px;border-top:1px solid var(--g100);">
|
||||
<label style="display:block;font-size:12px;font-weight:600;color:var(--g600);margin-bottom:4px;">Learning Hub — Default Browse Path</label>
|
||||
<small style="display:block;color:var(--g500);font-size:12px;margin-bottom:6px;">Folder opened first when picking files for AI content generation (e.g. <code>/Medical-Resources</code>)</small>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="nc-webdav-path" placeholder="/Medical-Resources" style="flex:1;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<button id="btn-nc-save-path" class="btn-sm btn-primary">Save Path</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Templates / Memories -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-book-medical"></i> My Templates</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes. You can reference them by saying "use my normal physical exam" in dictation.</p>
|
||||
<div style="margin-bottom:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<select id="mem-category" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="physical_exam">Physical Exam Template</option>
|
||||
<option value="ros">Review of Systems Template</option>
|
||||
<option value="encounter_format">Encounter Note Format</option>
|
||||
<option value="family_history">Family History Format</option>
|
||||
<option value="assessment_plan">Assessment & Plan Format</option>
|
||||
<option value="template_soap">SOAP Note Template</option>
|
||||
<option value="template_hpi">HPI Template</option>
|
||||
<option value="template_wellvisit">Well Visit Template</option>
|
||||
<option value="template_sickvisit">Sick Visit Template</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
|
||||
</div>
|
||||
<textarea id="mem-content" rows="5" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL. Ears: TMs clear. Throat: clear..."></textarea>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||
<button id="btn-mem-save" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Template</button>
|
||||
</div>
|
||||
<div id="mem-list" style="margin-top:12px;display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading templates...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents (S3) -->
|
||||
<div class="settings-section card" id="documents-section">
|
||||
<h3><i class="fas fa-file-arrow-up"></i> Documents</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Upload and manage documents via S3 storage (PDF, images, Word docs, text files). Max 10 MB per file.</p>
|
||||
<div id="doc-upload-area" style="margin-bottom:12px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<input type="file" id="doc-file-input" accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.txt,.csv" style="font-size:13px;">
|
||||
<input type="text" id="doc-description" placeholder="Description (optional)" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;">
|
||||
<button id="btn-doc-upload" class="btn-sm btn-primary"><i class="fas fa-upload"></i> Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="documents-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Corrections (Dragon-like memory) -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-brain"></i> AI Learning (Corrections)</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">The AI automatically learns from your edits. When you modify AI-generated text and save, corrections are stored here and applied to future notes. Latest 20 per section.</p>
|
||||
<div id="corrections-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading corrections...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Backups -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-microphone-lines"></i> Audio Backups</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Recordings are automatically backed up locally and kept for 24 hours. Retry transcription if it failed.</p>
|
||||
<div id="audio-backups-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Encounters -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-floppy-disk"></i> Saved Encounters</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Encounters are automatically deleted after 7 days per site policy.</p>
|
||||
<div id="saved-enc-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3>
|
||||
<div class="hipaa-info">
|
||||
<p><strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.</p>
|
||||
<ul>
|
||||
<li>✅ All connections use HTTPS/TLS encryption</li>
|
||||
<li>✅ Authentication with optional 2FA</li>
|
||||
<li>✅ No patient data stored on server beyond session</li>
|
||||
<li>✅ AWS Bedrock supports BAA for HIPAA compliance</li>
|
||||
<li>✅ Azure OpenAI supports BAA for HIPAA compliance</li>
|
||||
</ul>
|
||||
<p><strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-thermometer-half"></i> Sick Visit Note</h2>
|
||||
<p>Quick sick visit documentation with auto-suggested ROS and PE systems</p>
|
||||
</div>
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar-wrap">
|
||||
<div class="save-bar" id="sick-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="sick-label" class="save-label-input" placeholder="Patient label (e.g. John D, 5y fever)">
|
||||
</div>
|
||||
<button id="btn-sick-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-sick-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
<button id="btn-sick-new" class="btn-sm btn-ghost" title="Clear and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
|
||||
</div>
|
||||
<div id="sick-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="sick-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="sick-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Demographics -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-user"></i> Patient Info</h3></div>
|
||||
<div style="padding:12px 16px;display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;">
|
||||
<div class="demo-field"><label>Age</label><input type="text" id="sick-age" placeholder="e.g., 5 years" style="width:110px;"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="sick-gender"><option value="">Select</option><option>Male</option><option>Female</option><option>Non-binary/Other</option></select></div>
|
||||
<div class="demo-field" style="flex:1;min-width:200px;"><label>Chief Complaint</label><input type="text" id="sick-cc" placeholder="e.g., fever, ear pain, cough" style="width:100%;"></div>
|
||||
<div class="demo-field demo-field-model"><label><i class="fas fa-robot"></i> Model</label><select id="sick-model-select" class="tab-model-select"></select></div>
|
||||
<button id="btn-sick-infer" class="btn-sm btn-primary" style="align-self:flex-end;"><i class="fas fa-wand-magic-sparkles"></i> Update ROS/PE</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-microphone"></i> Encounter Recording / Dictation</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="sick-record-btn" class="btn-sm btn-ghost"><i class="fas fa-microphone"></i> Listen In</button>
|
||||
<button id="sick-pause-btn" class="btn-sm btn-ghost hidden"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="sick-rec-indicator" class="recording-indicator hidden" style="font-size:12px;"><div class="pulse-dot"></div><span>Recording... <span id="sick-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 16px;">
|
||||
<div id="sick-transcript" class="editable-box" contenteditable="true" data-placeholder="Transcript appears here. Physician can dictate the HPI and encounter details."></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ROS (4 systems auto-inferred) -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-list-check"></i> Review of Systems</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="sick-ros-all-wnl" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All WNL</button>
|
||||
<button id="sick-ros-clear" class="btn-sm btn-ghost" style="font-size:11px;color:var(--g500);"><i class="fas fa-eraser"></i> Clear</button>
|
||||
<select id="sick-ros-add" class="btn-sm" style="font-size:11px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="">+ Add system</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sick-ros-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- PE (7 systems auto-inferred) -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-stethoscope"></i> Physical Examination</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="sick-pe-all-normal" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All Normal</button>
|
||||
<button id="sick-pe-clear" class="btn-sm btn-ghost" style="font-size:11px;color:var(--g500);"><i class="fas fa-eraser"></i> Clear</button>
|
||||
<select id="sick-pe-add" class="btn-sm" style="font-size:11px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="">+ Add system</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sick-pe-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnoses -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-tag"></i> Diagnoses (ICD-10)</h3></div>
|
||||
<div id="sick-dx-container" style="padding:12px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<button id="btn-sick-generate" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate Sick Visit Note</button>
|
||||
|
||||
<!-- Output -->
|
||||
<div id="sick-note-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Sick Visit Note</h3>
|
||||
<div class="output-actions">
|
||||
<span id="sick-note-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-ghost" id="btn-sick-new-patient"><i class="fas fa-rotate-left"></i> New Patient</button>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="sick-note-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="sick-note-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sick-note-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<textarea id="sick-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify (e.g., 'add that patient received amoxicillin')"></textarea>
|
||||
<button class="btn-sm btn-primary" id="sick-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="sick-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-file-waveform"></i> SOAP Note Generator</h2>
|
||||
<p>Generate full SOAP note or just Subjective from dictation/text</p>
|
||||
</div>
|
||||
|
||||
<div class="demographics-bar">
|
||||
<div class="demo-field"><label>Patient Age</label><input type="text" id="soap-age" placeholder="e.g., 5 years"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="soap-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
|
||||
<div class="demo-field">
|
||||
<label>Generate</label>
|
||||
<select id="soap-type">
|
||||
<option value="full">Full SOAP Note</option>
|
||||
<option value="subjective">Subjective Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar-wrap">
|
||||
<div class="save-bar" id="soap-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="soap-label" class="save-label-input" placeholder="Patient label (e.g. John D, Visit #42)">
|
||||
</div>
|
||||
<button id="btn-soap-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-soap-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
<button id="btn-soap-new" class="btn-sm btn-ghost" title="Clear all and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
|
||||
</div>
|
||||
<div id="soap-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="soap-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="soap-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="record-controls">
|
||||
<button id="soap-record-btn" class="record-btn btn-teal"><i class="fas fa-microphone"></i><span>Dictate</span></button>
|
||||
<button id="soap-pause-btn" class="btn-sm btn-ghost hidden" style="margin-left:8px;"><i class="fas fa-pause"></i> Pause</button>
|
||||
<button id="soap-stop-btn" class="btn-sm hidden" style="margin-left:8px;background:var(--red);color:white;border:none;border-radius:8px;padding:7px 16px;cursor:pointer;font-size:13px;"><i class="fas fa-stop"></i> Stop</button>
|
||||
<div id="soap-recording-indicator" class="recording-indicator hidden"><div class="pulse-dot pulse-teal"></div><span>Dictating... <span id="soap-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-file-lines"></i> Input</h3><button id="soap-clear" class="btn-sm btn-ghost"><i class="fas fa-eraser"></i> Clear</button></div>
|
||||
<div id="soap-transcript" class="editable-box" contenteditable="true" data-placeholder="Dictate or type encounter details..."></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Instructions (optional)</h3></div>
|
||||
<textarea id="soap-instructions" class="full-input" rows="3" placeholder="e.g., 'Include assessment for otitis media', 'Add anticipatory guidance', 'Always include return precautions'"></textarea>
|
||||
</div>
|
||||
|
||||
<button id="soap-generate-btn" class="btn-generate btn-generate-teal"><i class="fas fa-wand-magic-sparkles"></i> Generate SOAP Note</button>
|
||||
|
||||
<div id="soap-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> SOAP Note</h3>
|
||||
<div class="output-actions">
|
||||
<span id="soap-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="soap-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="soap-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="soap-text" data-label="soap-note"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="soap-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<input type="text" id="soap-refine-input" class="refine-input" placeholder="Tell AI to modify...">
|
||||
<button class="btn-sm btn-primary" id="soap-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="soap-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-table"></i> Full Vaccine Schedule</h2>
|
||||
<p>AAP/ACIP 2025 — Complete immunization schedule for 0–18 years</p>
|
||||
</div>
|
||||
<div id="wv-panel-schedule">
|
||||
<p class="wv-loading">Loading schedule...</p>
|
||||
</div>
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-calendar-check"></i> Well Visit / Preventive Care</h2>
|
||||
<p>AAP 2025 Bright Futures periodicity — vaccines, screenings, billing codes, and catch-up schedule</p>
|
||||
</div>
|
||||
|
||||
<!-- Sub-tab navigation -->
|
||||
<div class="wv-subtab-bar">
|
||||
<button class="wv-subtab-btn active" data-subtab="byvisit">
|
||||
<i class="fas fa-child"></i> By Visit Age
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-subtab="milestones">
|
||||
<i class="fas fa-baby"></i> Milestones
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
|
||||
<i class="fas fa-brain"></i> SSHADESS (12+)
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-subtab="note">
|
||||
<i class="fas fa-file-medical"></i> Visit Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- By Visit sub-panel -->
|
||||
<div id="wv-panel-byvisit" class="wv-subpanel">
|
||||
<div class="wv-visit-selector-row">
|
||||
<label for="wv-visit-select"><i class="fas fa-calendar-day"></i> Select Visit Age:</label>
|
||||
<select id="wv-visit-select" class="wv-visit-select"></select>
|
||||
</div>
|
||||
<div id="wv-visit-detail" class="wv-visit-detail">
|
||||
<p class="wv-empty">Select a visit age above to see recommendations.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Milestones sub-panel -->
|
||||
<div id="wv-panel-milestones" class="wv-subpanel hidden">
|
||||
|
||||
<div class="demographics-bar">
|
||||
<div class="demo-field"><label>Patient Age</label><input type="text" id="ms-age" placeholder="e.g., 9 months"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="ms-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
|
||||
<div class="demo-field">
|
||||
<label>Age Group</label>
|
||||
<select id="ms-age-group">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="Newborn / 1 month">Newborn / 1 Month</option>
|
||||
<option value="2 months">2 Months</option>
|
||||
<option value="4 months">4 Months</option>
|
||||
<option value="6 months">6 Months</option>
|
||||
<option value="9 months">9 Months</option>
|
||||
<option value="12 months">12 Months</option>
|
||||
<option value="15 months">15 Months</option>
|
||||
<option value="18 months">18 Months</option>
|
||||
<option value="24 months">2 Years</option>
|
||||
<option value="30 months">2.5 Years</option>
|
||||
<option value="36 months">3 Years</option>
|
||||
<option value="48 months">4 Years</option>
|
||||
<option value="60 months">5 Years</option>
|
||||
<option value="6 years">6 Years</option>
|
||||
<option value="7 years">7 Years</option>
|
||||
<option value="8 years">8 Years</option>
|
||||
<option value="9 years">9 Years</option>
|
||||
<option value="10 years">10 Years</option>
|
||||
<option value="11 years">11 Years</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field">
|
||||
<label>Output Format</label>
|
||||
<select id="ms-format">
|
||||
<option value="narrative">Narrative (paragraphs)</option>
|
||||
<option value="list">Structured List</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-icon legend-yes">✓</span> Achieved</div>
|
||||
<div class="legend-item"><span class="legend-icon legend-no">✗</span> Not Achieved</div>
|
||||
<div class="legend-item"><span class="legend-icon legend-blank">—</span> Not Assessed (omitted)</div>
|
||||
</div>
|
||||
|
||||
<div id="milestone-checklist"></div>
|
||||
|
||||
<div class="milestone-actions" id="milestone-actions" style="display:none">
|
||||
<button id="ms-all-yes" class="btn-sm btn-success"><i class="fas fa-check-double"></i> All Yes</button>
|
||||
<button id="ms-all-clear" class="btn-sm btn-ghost"><i class="fas fa-eraser"></i> Clear All</button>
|
||||
<button id="ms-generate-btn" class="btn-generate btn-generate-teal"><i class="fas fa-wand-magic-sparkles"></i> Generate Narrative</button>
|
||||
</div>
|
||||
|
||||
<div id="ms-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Developmental Assessment</h3>
|
||||
<div class="output-actions">
|
||||
<span id="ms-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="ms-narrative-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-success" id="btn-ms-copy-to-note"><i class="fas fa-clipboard-check"></i> Copy to Note</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="ms-narrative-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="ms-narrative-text" data-label="milestones"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ms-summary-bar" class="summary-bar hidden"></div>
|
||||
<div id="ms-narrative-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="summary-section">
|
||||
<div class="summary-section-header">
|
||||
<button id="ms-quick-summary-btn" class="btn-generate-summary"><i class="fas fa-compress"></i> 3-Sentence Summary</button>
|
||||
</div>
|
||||
<div id="ms-quick-summary" class="hidden">
|
||||
<div class="card-header summary-output-header">
|
||||
<h3><i class="fas fa-clipboard-list"></i> Quick Summary</h3>
|
||||
<div class="output-actions">
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="ms-summary-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ms-summary-text" class="summary-output-text" contenteditable="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SSHADESS sub-panel (age 12+) -->
|
||||
<div id="wv-panel-shadess" class="wv-subpanel hidden">
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-brain"></i> SSHADESS Psychosocial Screening</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Recommended age 12 and older</span>
|
||||
</div>
|
||||
<div style="padding:12px 16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;border-bottom:1px solid var(--g100);">
|
||||
<div class="demo-field"><label>Patient Age</label><input type="text" id="shadess-age" placeholder="e.g., 14 years" style="width:120px;"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="shadess-gender"><option value="">Select</option><option>Male</option><option>Female</option><option>Non-binary/Other</option></select></div>
|
||||
<div class="demo-field demo-field-model"><label><i class="fas fa-robot"></i> Model</label><select class="tab-model-select"></select></div>
|
||||
<div style="margin-left:auto;display:flex;gap:8px;">
|
||||
<button id="shadess-record-btn" class="btn-sm btn-ghost"><i class="fas fa-microphone"></i> Listen In</button>
|
||||
<button id="shadess-pause-btn" class="btn-sm btn-ghost hidden"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="shadess-rec-indicator" class="recording-indicator hidden" style="font-size:12px;"><div class="pulse-dot"></div><span>Recording... <span id="shadess-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Domains will be rendered by JS -->
|
||||
<div id="shadess-domains" style="padding:8px 0;"></div>
|
||||
<div style="padding:12px 16px;display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid var(--g100);">
|
||||
<button id="btn-shadess-generate" class="btn-generate" style="flex:1;"><i class="fas fa-wand-magic-sparkles"></i> Generate SSHADESS Assessment</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SSHADESS Output -->
|
||||
<div id="shadess-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> SSHADESS Assessment</h3>
|
||||
<div class="output-actions">
|
||||
<span id="shadess-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-ghost" id="btn-shadess-new-patient"><i class="fas fa-rotate-left"></i> New Patient</button>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="shadess-result-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="shadess-result-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="shadess-result-text" class="output-text" contenteditable="true"></div>
|
||||
<div style="padding:10px 16px;font-size:12px;color:var(--g500);">
|
||||
<i class="fas fa-info-circle"></i> Use "Copy" to paste this into the Visit Note tab for a complete encounter note.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visit Note sub-panel -->
|
||||
<div id="wv-panel-note" class="wv-subpanel hidden">
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar-wrap">
|
||||
<div class="save-bar" id="wv-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="wv-label" class="save-label-input" placeholder="Patient label (e.g. Jane D, Visit #7)">
|
||||
</div>
|
||||
<button id="btn-wv-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-wv-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
<button id="btn-wv-new" class="btn-sm btn-ghost" title="Clear and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
|
||||
</div>
|
||||
<div id="wv-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="wv-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="wv-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-stethoscope"></i> Visit Details</h3></div>
|
||||
<div style="padding:12px 16px;display:flex;flex-wrap:wrap;gap:10px;">
|
||||
<div class="demo-field"><label>Visit Age</label><input type="text" id="wv-note-age" placeholder="e.g., 9 years"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="wv-note-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
|
||||
<div class="demo-field demo-field-model"><label><i class="fas fa-robot"></i> Model</label><select class="tab-model-select"></select></div>
|
||||
<div style="width:100%;display:flex;gap:8px;align-items:center;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);">Note style:</label>
|
||||
<select id="wv-note-style" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="full">Full Encounter Note</option>
|
||||
<option value="short">Brief SOAP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-ruler-combined"></i> Vitals & Measurements</h3>
|
||||
</div>
|
||||
<div style="padding:12px 16px;">
|
||||
<textarea id="wv-vitals" rows="3" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="e.g., Temp 98.6, HR 78, RR 18, BP 106/68, O2 99% Ht: 135cm (50th%), Wt: 32kg (55th%), BMI: 17.6 (60th%)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-microphone"></i> Encounter Recording / Dictation</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="wv-record-btn" class="btn-sm btn-ghost"><i class="fas fa-microphone"></i> Listen In</button>
|
||||
<button id="wv-pause-btn" class="btn-sm btn-ghost hidden"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="wv-rec-indicator" class="recording-indicator hidden" style="font-size:12px;"><div class="pulse-dot"></div><span>Recording... <span id="wv-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 16px;">
|
||||
<div id="wv-transcript" class="editable-box" contenteditable="true" style="min-height:80px;" data-placeholder="Transcript of the encounter appears here. You can also type or paste directly. Physician can dictate: 'All physical exam normal aside from...'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-syringe"></i> Screenings & Vaccines</h3></div>
|
||||
<div style="padding:12px 16px;display:flex;flex-direction:column;gap:8px;">
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Screenings completed today:</label>
|
||||
<textarea id="wv-screenings" rows="2" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="e.g., Depression screen (PHQ-A): negative, Vision screen: 20/20 bilateral, Lead level: 2 ug/dL"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Vaccines given today:</label>
|
||||
<textarea id="wv-vaccines" rows="2" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="e.g., Tdap #1, Meningococcal (MenACWY) #1, HPV #1"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSHADESS carry-over -->
|
||||
<div class="card" style="margin-bottom:10px;" id="wv-shadess-card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-brain"></i> SSHADESS Assessment</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">From SSHADESS tab (age 12+)</span>
|
||||
</div>
|
||||
<div style="padding:8px 16px;">
|
||||
<textarea id="wv-shadess-text" rows="4" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste SSHADESS assessment here, or it auto-fills from the SSHADESS tab..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Milestones carry-over -->
|
||||
<div class="card" style="margin-bottom:10px;" id="wv-milestones-card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-baby"></i> Developmental Assessment</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">From Milestones tab</span>
|
||||
</div>
|
||||
<div style="padding:8px 16px;">
|
||||
<textarea id="wv-milestones-text" rows="3" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Developmental assessment auto-fills when you click 'Copy to Note' on Milestones tab..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ROS Card -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-list-check"></i> Review of Systems (ROS)</h3>
|
||||
<button id="wv-ros-all-wnl" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All WNL</button>
|
||||
<button id="wv-ros-clear" class="btn-sm btn-ghost" style="font-size:11px;color:var(--g500);"><i class="fas fa-eraser"></i> Clear</button>
|
||||
</div>
|
||||
<div id="wv-ros-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Exam Card -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-stethoscope"></i> Physical Examination (PE)</h3>
|
||||
<button id="wv-pe-all-normal" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All Normal</button>
|
||||
<button id="wv-pe-clear" class="btn-sm btn-ghost" style="font-size:11px;color:var(--g500);"><i class="fas fa-eraser"></i> Clear</button>
|
||||
</div>
|
||||
<div id="wv-pe-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnoses Card -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-tag"></i> Diagnoses (ICD-10)</h3></div>
|
||||
<div id="wv-dx-container" style="padding:12px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<button id="btn-wv-generate" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate Well Visit Note</button>
|
||||
|
||||
<div id="wv-note-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Well Visit Note</h3>
|
||||
<div class="output-actions">
|
||||
<span id="wv-note-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-ghost" id="btn-wv-new-patient"><i class="fas fa-rotate-left"></i> New Patient</button>
|
||||
<button class="btn-sm btn-primary" data-action="copy" data-target="wv-note-text"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="wv-note-text"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="wv-note-text" data-label="well-visit"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wv-note-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<textarea id="wv-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify (e.g., 'add that patient has asthma', 'expand the plan section')"></textarea>
|
||||
<button class="btn-sm btn-primary" id="wv-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="wv-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1,855 +0,0 @@
|
|||
:root {
|
||||
--blue: #2563eb; --blue-dark: #1d4ed8; --blue-light: #dbeafe;
|
||||
--purple: #7c3aed; --purple-light: #ede9fe;
|
||||
--teal: #0891b2; --teal-light: #cffafe;
|
||||
--green: #10b981; --green-light: #d1fae5;
|
||||
--red: #ef4444; --red-light: #fee2e2;
|
||||
--amber: #f59e0b; --amber-light: #fef3c7;
|
||||
--g50:#f9fafb;--g100:#f3f4f6;--g200:#e5e7eb;--g300:#d1d5db;--g400:#9ca3af;
|
||||
--g500:#6b7280;--g600:#4b5563;--g700:#374151;--g800:#1f2937;--g900:#111827;
|
||||
--radius:12px; --shadow:0 1px 3px rgba(0,0,0,0.08); --shadow-md:0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--g800);line-height:1.6;}
|
||||
|
||||
/* AUTH */
|
||||
.auth-screen{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;background:var(--g50);padding:20px;}
|
||||
.auth-box{background:white;border-radius:20px;padding:40px;max-width:440px;width:100%;box-shadow:0 4px 6px -1px rgba(0,0,0,.07),0 20px 60px -10px rgba(37,99,235,.12);border:1px solid #e0e7ff;}
|
||||
.auth-logo{text-align:center;margin-bottom:30px;}
|
||||
.auth-logo i{font-size:40px;color:var(--blue);margin-bottom:10px;}
|
||||
.auth-logo h1{font-size:24px;color:var(--g900);}
|
||||
.auth-logo p{color:var(--g500);font-size:14px;}
|
||||
.auth-form h2{font-size:20px;margin-bottom:20px;color:var(--g800);}
|
||||
.form-group{margin-bottom:16px;}
|
||||
.form-group label{display:block;font-size:13px;font-weight:600;color:var(--g600);margin-bottom:4px;}
|
||||
.form-group input{width:100%;padding:10px 14px;border:1.5px solid var(--g300);border-radius:8px;font-size:14px;font-family:inherit;}
|
||||
.form-group input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||
.pw-breach-warning{margin-top:6px;padding:8px 10px;background:#fffbeb;border:1px solid #fcd34d;border-radius:6px;font-size:12px;color:#92400e;line-height:1.4;}
|
||||
.pw-breach-warning i{margin-right:4px;color:#f59e0b;}
|
||||
.form-group small{color:var(--g500);font-size:12px;}
|
||||
.btn-auth{width:100%;padding:12px;background:var(--blue);color:white;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:background 0.2s;}
|
||||
.btn-auth:hover{background:var(--blue-dark);}
|
||||
.auth-links{text-align:center;margin-top:16px;font-size:13px;}
|
||||
.auth-links a{color:var(--blue);text-decoration:none;margin:0 8px;}
|
||||
.hipaa-notice{margin-top:24px;padding:12px;background:var(--amber-light);border-radius:8px;font-size:12px;color:#92400e;display:flex;align-items:flex-start;gap:8px;}
|
||||
.hipaa-notice i{margin-top:2px;flex-shrink:0;}
|
||||
|
||||
/* FOOTER */
|
||||
.app-footer{color:rgba(255,255,255,0.4);font-size:10.5px;text-align:center;padding:20px 24px 8px;line-height:1.6;}
|
||||
|
||||
|
||||
/* HEADER */
|
||||
.app-header{background:linear-gradient(135deg,var(--blue) 0%,var(--purple) 100%);color:white;padding:14px 24px;}
|
||||
.header-content{max-width:1200px;margin:0 auto;}
|
||||
.header-top{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;}
|
||||
.logo{display:flex;align-items:center;gap:12px;}
|
||||
.logo i{font-size:26px;}
|
||||
.logo h1{font-size:20px;font-weight:700;}
|
||||
.tagline{font-size:12px;opacity:0.8;}
|
||||
.header-right{display:flex;align-items:center;gap:12px;}
|
||||
.model-selector{display:flex;align-items:center;gap:6px;background:rgba(255,255,255,0.15);padding:6px 12px;border-radius:8px;}
|
||||
.model-selector label{font-size:12px;}
|
||||
.model-selector select{padding:4px 8px;border:1px solid rgba(255,255,255,0.3);border-radius:6px;background:rgba(255,255,255,0.2);color:white;font-size:12px;font-family:inherit;max-width:220px;}
|
||||
.model-selector select option{background:white;color:var(--g800);}
|
||||
.cost-badge{font-size:10px;padding:2px 8px;background:rgba(16,185,129,0.3);border:1px solid rgba(16,185,129,0.5);border-radius:10px;color:#6ee7b7;font-weight:600;white-space:nowrap;}
|
||||
.header-buttons{display:flex;gap:6px;}
|
||||
.btn-header{background:rgba(255,255,255,0.15);border:none;color:white;width:36px;height:36px;border-radius:8px;cursor:pointer;font-size:14px;transition:background 0.2s;}
|
||||
.btn-header:hover{background:rgba(255,255,255,0.3);}
|
||||
|
||||
/* APP BODY LAYOUT */
|
||||
.app-body{display:flex;min-height:calc(100vh - 66px);}
|
||||
|
||||
/* SIDEBAR */
|
||||
.sidebar{width:210px;flex-shrink:0;background:white;border-right:1.5px solid var(--g200);display:flex;flex-direction:column;position:sticky;top:0;height:calc(100vh - 66px);overflow-y:auto;z-index:100;transition:width 0.22s ease;}
|
||||
.sidebar.collapsed{width:0;border:none;overflow:hidden;}
|
||||
.sidebar-header{display:none;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--g200);}
|
||||
.sidebar-title{font-size:13px;font-weight:700;color:var(--g700);}
|
||||
.sidebar-nav{display:flex;flex-direction:column;padding:8px 0;flex:1;}
|
||||
.sidebar-section-label{font-size:10px;font-weight:700;color:var(--g400);text-transform:uppercase;letter-spacing:0.7px;padding:10px 16px 4px;}
|
||||
.tab-btn{display:flex;align-items:center;gap:10px;padding:10px 16px;border:none;border-left:3px solid transparent;background:none;color:var(--g600);font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;font-family:inherit;transition:all 0.15s;width:100%;text-align:left;}
|
||||
.tab-btn:hover{background:var(--g50);color:var(--g900);}
|
||||
.tab-btn.active{background:var(--blue-light);color:var(--blue);border-left-color:var(--blue);font-weight:600;}
|
||||
.tab-btn i{width:16px;text-align:center;flex-shrink:0;font-size:14px;}
|
||||
.tab-btn span{overflow:hidden;text-overflow:ellipsis;}
|
||||
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:199;}
|
||||
.btn-menu-toggle{display:none;}
|
||||
/* Sidebar collapse/expand buttons (desktop only) */
|
||||
.sidebar-pin-btn{display:flex;align-items:center;justify-content:center;padding:10px;border:none;background:none;color:var(--g400);cursor:pointer;font-size:13px;border-top:1px solid var(--g200);transition:color 0.15s;flex-shrink:0;}
|
||||
.sidebar-pin-btn:hover{color:var(--blue);}
|
||||
.sidebar-expand-btn{position:fixed;left:0;top:50%;transform:translateY(-50%);z-index:101;background:white;border:1.5px solid var(--g200);border-left:none;border-radius:0 8px 8px 0;padding:10px 6px;color:var(--g500);cursor:pointer;font-size:13px;box-shadow:2px 0 8px rgba(0,0,0,0.06);transition:color 0.15s;}
|
||||
.sidebar-expand-btn:hover{color:var(--blue);}
|
||||
|
||||
/* MAIN */
|
||||
.main-content{flex:1;min-width:0;padding:20px 20px;}
|
||||
.tab-content{display:none;}
|
||||
.tab-content.active{display:block;animation:fadeIn 0.25s ease;}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}
|
||||
|
||||
.module-header{margin-bottom:16px;}
|
||||
.module-header h2{font-size:18px;font-weight:700;display:flex;align-items:center;gap:8px;}
|
||||
.module-header p{color:var(--g500);font-size:13px;margin-top:2px;}
|
||||
|
||||
/* DEMOGRAPHICS */
|
||||
.demographics-bar{display:flex;gap:12px;flex-wrap:wrap;background:white;padding:12px 16px;border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:12px;}
|
||||
.demo-field{display:flex;flex-direction:column;gap:3px;}
|
||||
.demo-field label{font-size:10px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.5px;}
|
||||
.demo-field input,.demo-field select{padding:7px 10px;border:1.5px solid var(--g300);border-radius:8px;font-size:13px;font-family:inherit;min-width:130px;}
|
||||
.demo-field input:focus,.demo-field select:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||
.soap-options{display:flex;align-items:center;gap:10px;margin-bottom:12px;font-size:13px;font-weight:500;color:var(--g600);}
|
||||
.soap-options select{padding:6px 10px;border:1.5px solid var(--g300);border-radius:8px;font-size:13px;font-family:inherit;}
|
||||
|
||||
/* CARDS */
|
||||
.card{background:white;border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:12px;overflow:hidden;}
|
||||
.card-header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:var(--g50);border-bottom:1px solid var(--g200);}
|
||||
.card-header h3{font-size:13px;font-weight:600;color:var(--g700);display:flex;align-items:center;gap:6px;}
|
||||
|
||||
/* EDITABLE BOX */
|
||||
.editable-box{min-height:240px;max-height:600px;overflow-y:auto;padding:14px 18px;font-size:13px;line-height:1.8;outline:none;border-radius:0 0 var(--radius) var(--radius);}
|
||||
.editable-box:empty::before{content:attr(data-placeholder);color:var(--g400);}
|
||||
|
||||
/* NOTE ENTRY (hospital course, chart review) */
|
||||
.note-entry{padding:10px 16px;}
|
||||
.note-meta{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;}
|
||||
.note-meta input,.note-meta select{padding:6px 10px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;}
|
||||
.note-dictate{padding-top:6px;}
|
||||
.lab-entry{display:flex;gap:8px;padding:6px 16px;align-items:center;flex-wrap:wrap;}
|
||||
.lab-entry input{padding:6px 10px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;flex:1;min-width:120px;}
|
||||
.full-input{width:100%;padding:10px 16px;border:none;font-size:13px;font-family:inherit;outline:none;border-top:1px solid var(--g200);}
|
||||
.action-row{margin-bottom:12px;}
|
||||
|
||||
/* RECORDING */
|
||||
.record-controls{display:flex;align-items:center;gap:14px;padding:14px 16px;}
|
||||
.record-btn{display:flex;align-items:center;gap:8px;padding:10px 22px;background:var(--red);color:white;border:none;border-radius:50px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.2s;box-shadow:0 3px 10px rgba(239,68,68,0.3);}
|
||||
.record-btn:hover{transform:translateY(-1px);}
|
||||
.record-btn.recording{background:var(--g700);box-shadow:0 3px 10px rgba(0,0,0,0.2);}
|
||||
.btn-purple{background:var(--purple);box-shadow:0 3px 10px rgba(124,58,237,0.3);}
|
||||
.btn-teal{background:var(--teal);box-shadow:0 3px 10px rgba(8,145,178,0.3);}
|
||||
.btn-recording{background:var(--red) !important;color:white !important;animation:btnpulse 1.5s infinite;}
|
||||
.recording-indicator{display:flex;align-items:center;gap:6px;color:var(--red);font-weight:500;font-size:13px;}
|
||||
.recording-indicator.hidden{display:none;}
|
||||
.pulse-dot{width:10px;height:10px;background:var(--red);border-radius:50%;animation:pulse 1.5s infinite;}
|
||||
.pulse-purple{background:var(--purple);}
|
||||
.pulse-teal{background:var(--teal);}
|
||||
@keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(1.3);}}
|
||||
@keyframes btnpulse{0%,100%{opacity:1;}50%{opacity:0.7;}}
|
||||
|
||||
/* BUTTONS */
|
||||
.btn-generate{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:12px;background:var(--blue);color:white;border:none;border-radius:var(--radius);font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;margin-bottom:12px;transition:all 0.2s;box-shadow:0 3px 10px rgba(37,99,235,0.25);}
|
||||
.btn-generate:hover{transform:translateY(-1px);}
|
||||
.btn-generate:disabled{background:var(--g300);cursor:not-allowed;transform:none;box-shadow:none;}
|
||||
.btn-generate-purple{background:var(--purple);box-shadow:0 3px 10px rgba(124,58,237,0.25);}
|
||||
.btn-generate-teal{background:var(--teal);box-shadow:0 3px 10px rgba(8,145,178,0.25);}
|
||||
.btn-generate-blue{background:var(--blue);box-shadow:0 3px 10px rgba(37,99,235,0.25);}
|
||||
.btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);}
|
||||
|
||||
.btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:none;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:all 0.15s;}
|
||||
.btn-lg{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;}
|
||||
.btn-primary{background:var(--blue);color:white;}.btn-primary:hover{background:var(--blue-dark);}
|
||||
.btn-ghost{background:var(--g200);color:var(--g700);}.btn-ghost:hover{background:var(--g300);}
|
||||
.btn-success{background:var(--green);color:white;}.btn-success:hover{background:#059669;}
|
||||
.btn-warning{background:var(--amber);color:white;}.btn-warning:hover{background:#d97706;}
|
||||
.btn-reading{background:var(--red)!important;color:white!important;animation:btnpulse 1.5s infinite;}
|
||||
|
||||
/* OUTPUT */
|
||||
.output-card{border:2px solid var(--green);}
|
||||
.output-card.hidden{display:none;}
|
||||
.output-header{background:var(--green-light);border-bottom-color:var(--green);}
|
||||
.output-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
||||
.output-text{padding:20px 24px;font-size:13px;line-height:1.9;outline:none;min-height:480px;white-space:pre-wrap;overflow-y:auto;}
|
||||
.output-text-large{min-height:600px;}
|
||||
.editable-box-large{min-height:400px;}
|
||||
.model-tag{font-size:10px;padding:2px 7px;background:var(--g200);border-radius:4px;color:var(--g600);}
|
||||
|
||||
/* REFINE BAR */
|
||||
.refine-bar{display:flex;gap:6px;padding:10px 16px;border-top:1px solid var(--g200);background:var(--g50);align-items:flex-start;flex-wrap:wrap;}
|
||||
.refine-input{flex:1;min-width:200px;padding:7px 12px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;resize:vertical;}
|
||||
textarea.full-input{resize:vertical;}
|
||||
.refine-input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||
|
||||
/* CLARIFY BOX */
|
||||
.clarify-box{padding:14px 16px;background:var(--amber-light);border-top:1px solid #fbbf24;font-size:13px;line-height:1.8;white-space:pre-wrap;color:#92400e;}
|
||||
.clarify-box.hidden{display:none;}
|
||||
|
||||
/* MILESTONES */
|
||||
.legend{display:flex;gap:16px;flex-wrap:wrap;padding:8px 14px;background:var(--teal-light);border-radius:var(--radius);margin-bottom:12px;font-size:12px;}
|
||||
.legend-item{display:flex;align-items:center;gap:5px;}
|
||||
.legend-icon{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border-radius:6px;font-size:12px;font-weight:700;}
|
||||
.legend-yes{background:var(--green-light);color:var(--green);border:2px solid var(--green);}
|
||||
.legend-no{background:var(--red-light);color:var(--red);border:2px solid var(--red);}
|
||||
.legend-blank{background:var(--g100);color:var(--g400);border:2px solid var(--g300);}
|
||||
|
||||
.domain-section{background:white;border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:10px;overflow:hidden;}
|
||||
.domain-header{padding:10px 16px;font-weight:700;font-size:13px;display:flex;align-items:center;gap:6px;}
|
||||
.domain-header .badge{margin-left:auto;font-size:10px;font-weight:500;padding:2px 8px;border-radius:8px;background:rgba(255,255,255,0.6);}
|
||||
.domain-gross-motor .domain-header{background:var(--amber-light);color:#92400e;}
|
||||
.domain-fine-motor .domain-header{background:var(--blue-light);color:#1e40af;}
|
||||
.domain-language .domain-header{background:var(--purple-light);color:#5b21b6;}
|
||||
.domain-social .domain-header{background:#fce7f3;color:#9d174d;}
|
||||
.domain-cognitive .domain-header{background:var(--green-light);color:#065f46;}
|
||||
|
||||
.ms-items{padding:4px 6px;}
|
||||
.ms-item{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:6px;transition:background 0.15s;}
|
||||
.ms-item:hover{background:var(--g50);}
|
||||
.ms-toggle{display:flex;gap:3px;flex-shrink:0;}
|
||||
.toggle-btn{width:30px;height:30px;display:flex;align-items:center;justify-content:center;border:2px solid var(--g300);border-radius:6px;background:white;cursor:pointer;font-size:14px;font-weight:700;color:var(--g400);transition:all 0.15s;}
|
||||
.toggle-btn:hover{border-color:var(--g400);}
|
||||
.toggle-btn.yes-on{background:var(--green-light);border-color:var(--green);color:var(--green);}
|
||||
.toggle-btn.no-on{background:var(--red-light);border-color:var(--red);color:var(--red);}
|
||||
.ms-text{font-size:13px;color:var(--g700);}
|
||||
.ms-item.is-yes .ms-text{color:var(--g900);font-weight:500;}
|
||||
.ms-item.is-no .ms-text{color:var(--red);}
|
||||
.milestone-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:12px 0;}
|
||||
|
||||
.summary-bar{display:flex;gap:14px;padding:8px 16px;background:var(--g50);border-bottom:1px solid var(--g200);font-size:12px;}
|
||||
.summary-bar.hidden{display:none;}
|
||||
.stat{display:flex;align-items:center;gap:5px;font-weight:500;}
|
||||
.dot{width:8px;height:8px;border-radius:50%;}.dot-green{background:var(--green);}.dot-red{background:var(--red);}.dot-gray{background:var(--g400);}
|
||||
|
||||
.summary-section{border-top:2px dashed var(--g200);}
|
||||
.summary-section-header{padding:12px 16px;}
|
||||
.btn-generate-summary{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;background:linear-gradient(135deg,#f59e0b,#d97706);color:white;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.2s;}
|
||||
.btn-generate-summary:hover{transform:translateY(-1px);}
|
||||
.btn-generate-summary:disabled{background:var(--g300);cursor:not-allowed;transform:none;}
|
||||
.summary-output-header{background:var(--amber-light);border-bottom:1px solid #fbbf24;border-top:1px solid var(--g200);}
|
||||
.summary-output-text{padding:14px 16px;font-size:13px;line-height:1.8;outline:none;background:#fffbeb;font-weight:500;white-space:pre-wrap;}
|
||||
|
||||
/* MODAL */
|
||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:5000;padding:20px;backdrop-filter:blur(3px);}
|
||||
.modal.hidden{display:none;}
|
||||
.modal-content{background:white;border-radius:16px;max-width:600px;width:100%;max-height:90vh;overflow-y:auto;box-shadow:0 20px 40px rgba(0,0,0,0.2);}
|
||||
.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 24px;border-bottom:1px solid var(--g200);}
|
||||
.modal-header h2{font-size:18px;display:flex;align-items:center;gap:8px;}
|
||||
.modal-close{background:none;border:none;font-size:18px;color:var(--g500);cursor:pointer;padding:4px;}
|
||||
.modal-body{padding:24px;}
|
||||
.settings-section{margin-bottom:28px;}
|
||||
.settings-section h3{font-size:15px;font-weight:600;margin-bottom:10px;display:flex;align-items:center;gap:8px;color:var(--g800);}
|
||||
.settings-section p{font-size:13px;color:var(--g600);margin-bottom:8px;}
|
||||
.settings-section .form-group{margin-bottom:12px;}
|
||||
.settings-section img{max-width:200px;margin:12px 0;border:1px solid var(--g200);border-radius:8px;}
|
||||
.settings-section code{background:var(--g100);padding:2px 8px;border-radius:4px;font-size:12px;}
|
||||
.hipaa-info{font-size:13px;color:var(--g700);}
|
||||
.hipaa-info ul{margin:8px 0 8px 20px;}
|
||||
.hipaa-info li{margin-bottom:4px;}
|
||||
|
||||
/* LOADING */
|
||||
.loading-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;backdrop-filter:blur(3px);}
|
||||
.loading-overlay.hidden{display:none;}
|
||||
.loading-box{background:white;padding:28px 44px;border-radius:var(--radius);text-align:center;box-shadow:var(--shadow-md);}
|
||||
.spinner{width:32px;height:32px;border:4px solid var(--g200);border-top-color:var(--blue);border-radius:50%;animation:spin 0.7s linear infinite;margin:0 auto 12px;}
|
||||
@keyframes spin{to{transform:rotate(360deg);}}
|
||||
.loading-box p{color:var(--g600);font-weight:500;font-size:13px;}
|
||||
.confirm-modal{position:fixed;inset:0;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;z-index:10000;backdrop-filter:blur(2px);}
|
||||
.confirm-modal.hidden{display:none;}
|
||||
.confirm-modal-box{background:white;padding:24px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.15);max-width:420px;width:90%;animation:modalIn 0.15s ease;}
|
||||
.confirm-modal-box p{margin:0;font-size:14px;line-height:1.6;color:var(--g800);}
|
||||
@keyframes modalIn{from{opacity:0;transform:scale(0.95)} to{opacity:1;transform:scale(1)}}
|
||||
|
||||
/* FAQ */
|
||||
.faq-container{max-width:800px;margin:0 auto;}
|
||||
.faq-section{margin-bottom:24px;}
|
||||
.faq-section-title{font-size:16px;font-weight:700;color:var(--g800);margin:0 0 12px;padding:10px 0;border-bottom:2px solid var(--g200);display:flex;align-items:center;gap:8px;}
|
||||
.faq-section-title i{color:var(--blue);font-size:14px;}
|
||||
.faq-item{border:1px solid var(--g200);border-radius:8px;margin-bottom:6px;overflow:hidden;transition:box-shadow 0.15s;}
|
||||
.faq-item:hover{box-shadow:0 1px 4px rgba(0,0,0,0.06);}
|
||||
.faq-question{display:flex;align-items:center;width:100%;padding:14px 16px;background:white;border:none;cursor:pointer;font-size:14px;font-weight:600;color:var(--g800);text-align:left;font-family:inherit;gap:10px;transition:background 0.15s;}
|
||||
.faq-question:hover{background:var(--g50);}
|
||||
.faq-question::before{content:'\f054';font-family:'Font Awesome 6 Free';font-weight:900;font-size:10px;color:var(--g400);transition:transform 0.2s;flex-shrink:0;}
|
||||
.faq-item.open .faq-question::before{transform:rotate(90deg);color:var(--blue);}
|
||||
.faq-item.open .faq-question{background:var(--g50);color:var(--blue-dark);}
|
||||
.faq-answer{max-height:0;overflow:hidden;transition:max-height 0.3s ease,padding 0.3s ease;padding:0 16px;}
|
||||
.faq-item.open .faq-answer{max-height:800px;padding:12px 16px 16px;}
|
||||
.faq-answer p{margin:0 0 10px;font-size:13.5px;line-height:1.7;color:var(--g700);}
|
||||
.faq-answer p:last-child{margin-bottom:0;}
|
||||
.faq-answer ul,.faq-answer ol{margin:0 0 10px;padding-left:20px;font-size:13.5px;line-height:1.7;color:var(--g700);}
|
||||
.faq-answer li{margin-bottom:4px;}
|
||||
.faq-answer strong{color:var(--g800);}
|
||||
.faq-answer em{color:var(--g500);font-size:12.5px;}
|
||||
|
||||
/* Billing codes */
|
||||
.billing-codes-card{margin-top:12px;padding:14px;background:var(--g50);border-radius:8px;border:1px solid var(--g200);}
|
||||
.billing-codes-card.hidden{display:none;}
|
||||
.billing-codes-card h4{font-size:13px;font-weight:700;color:var(--g700);margin:0 0 8px;display:flex;align-items:center;gap:6px;}
|
||||
.billing-codes-section{margin-bottom:10px;}
|
||||
.billing-codes-section:last-child{margin-bottom:0;}
|
||||
.billing-codes-label{font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;}
|
||||
.billing-code-chip{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;margin:3px;transition:opacity 0.15s;}
|
||||
.billing-code-chip.icd{background:#dbeafe;color:#1d4ed8;border:1px solid #93c5fd;}
|
||||
.billing-code-chip.cpt{background:#d1fae5;color:#059669;border:1px solid #6ee7b7;}
|
||||
.billing-code-chip.em{background:#ede9fe;color:#7c3aed;border:1px solid #c4b5fd;}
|
||||
.billing-code-chip:hover{opacity:0.7;}
|
||||
.billing-code-chip:active{transform:scale(0.95);}
|
||||
.billing-code-name{font-weight:400;color:var(--g600);font-size:11px;margin-left:2px;}
|
||||
|
||||
/* Calculators */
|
||||
.calc-pill,.calc-nav-pill{padding:8px 16px;border-radius:20px;border:1.5px solid var(--g200);background:white;font-size:13px;font-weight:600;color:var(--g600);cursor:pointer;transition:all 0.15s;}
|
||||
.calc-pill:hover,.calc-nav-pill:hover{border-color:var(--blue);color:var(--blue);}
|
||||
.calc-pill.active,.calc-nav-pill.active{background:var(--blue);color:white;border-color:var(--blue);}
|
||||
.calc-panel.hidden{display:none;}
|
||||
.calc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,180px),1fr));gap:12px;}
|
||||
.calc-field label{display:block;font-size:12px;font-weight:600;color:var(--g600);margin-bottom:3px;}
|
||||
.calc-field input,.calc-field select{width:100%;padding:8px 10px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;}
|
||||
.calc-field input:focus,.calc-field select:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||
.calc-result{margin-top:16px;}
|
||||
.calc-result-header{padding:16px;border-radius:10px;border-left:4px solid;text-align:center;}
|
||||
.calc-result-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-top:12px;}
|
||||
.calc-result-item{padding:12px;background:var(--g50);border-radius:8px;text-align:center;}
|
||||
.calc-result-label{font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.5px;}
|
||||
.calc-result-value{font-size:24px;font-weight:700;color:var(--g800);margin:4px 0;}
|
||||
.calc-result-sub{font-size:11px;color:var(--g500);}
|
||||
.chart-container{margin-top:16px;padding:12px;background:white;border:1px solid var(--g200);border-radius:10px;max-width:750px;}
|
||||
.chart-container.hidden{display:none;}
|
||||
|
||||
/* Non-blocking inline busy bar */
|
||||
.busy-bar{position:sticky;top:0;z-index:100;display:none;align-items:center;gap:8px;padding:8px 16px;background:linear-gradient(135deg,#eff6ff,#e0f2fe);border-bottom:1px solid #bfdbfe;font-size:13px;font-weight:500;color:#1d4ed8;animation:busySlideIn 0.2s ease;}
|
||||
.busy-bar.active{display:flex;}
|
||||
.busy-bar .busy-spinner{width:16px;height:16px;border:2.5px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:spin 0.7s linear infinite;flex-shrink:0;}
|
||||
.busy-bar .busy-text{flex:1;}
|
||||
.busy-bar .busy-cancel{background:none;border:1px solid #93c5fd;border-radius:4px;padding:3px 10px;font-size:11px;color:#1d4ed8;cursor:pointer;font-weight:600;}
|
||||
.busy-bar .busy-cancel:hover{background:#dbeafe;}
|
||||
@keyframes busySlideIn{from{opacity:0;transform:translateY(-100%);} to{opacity:1;transform:translateY(0);}}
|
||||
|
||||
/* TOAST */
|
||||
.toast-container{position:fixed;bottom:16px;right:16px;display:flex;flex-direction:column;gap:6px;z-index:10000;}
|
||||
.toast{padding:8px 16px;border-radius:8px;color:white;font-size:12px;font-weight:500;box-shadow:var(--shadow-md);animation:slideIn 0.3s ease;display:flex;align-items:center;gap:6px;}
|
||||
.toast-success{background:var(--green);}.toast-error{background:var(--red);}.toast-info{background:var(--blue);}
|
||||
@keyframes slideIn{from{transform:translateX(100%);opacity:0;}to{transform:translateX(0);opacity:1;}}
|
||||
|
||||
.hidden{display:none;}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media(max-width:768px){
|
||||
.header-top{flex-direction:row;align-items:center;}
|
||||
.header-right{flex-wrap:wrap;}
|
||||
.model-selector{flex:1;}
|
||||
.model-selector select{max-width:160px;flex:1;}
|
||||
.btn-menu-toggle{display:flex;align-items:center;justify-content:center;}
|
||||
/* Sidebar: hidden off-screen on mobile, slides in */
|
||||
.app-body{display:block;}
|
||||
.sidebar{position:fixed;left:-220px;top:0;width:220px;height:100vh;z-index:200;transition:left 0.28s ease;box-shadow:none;}
|
||||
.sidebar.open{left:0;box-shadow:4px 0 20px rgba(0,0,0,0.18);}
|
||||
.sidebar.open~.sidebar-overlay{display:block;}
|
||||
.sidebar-header{display:flex;}
|
||||
/* Hide desktop collapse controls on mobile */
|
||||
.sidebar-pin-btn{display:none;}
|
||||
.sidebar-expand-btn{display:none !important;}
|
||||
.main-content{padding:12px;}
|
||||
.demographics-bar{flex-direction:column;}
|
||||
.demo-field input,.demo-field select{min-width:100%;}
|
||||
.note-meta{flex-direction:column;}
|
||||
.note-meta input,.note-meta select{min-width:100%;}
|
||||
.refine-bar{flex-direction:column;}
|
||||
.refine-input{min-width:100%;}
|
||||
.milestone-actions{flex-direction:column;}
|
||||
.auth-box{padding:24px;}
|
||||
}
|
||||
|
||||
/* TOGGLE SWITCH */
|
||||
.toggle-switch{position:relative;display:inline-block;width:36px;height:20px;vertical-align:middle;}
|
||||
.toggle-switch input{opacity:0;width:0;height:0;}
|
||||
.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--g300);border-radius:20px;transition:.2s;}
|
||||
.toggle-slider::before{content:'';position:absolute;height:16px;width:16px;left:2px;bottom:2px;background:white;border-radius:50%;transition:.2s;}
|
||||
.toggle-switch input:checked+.toggle-slider{background:var(--blue);}
|
||||
.toggle-switch input:checked+.toggle-slider::before{transform:translateX(16px);}
|
||||
|
||||
.editable-box::-webkit-scrollbar,.output-text::-webkit-scrollbar{width:5px;}
|
||||
.editable-box::-webkit-scrollbar-thumb,.output-text::-webkit-scrollbar-thumb{background:var(--g300);border-radius:3px;}
|
||||
|
||||
/* Per-tab model selector */
|
||||
.demo-field-model { flex: 1; min-width: 180px; }
|
||||
.demo-field-model select { min-width: 180px; max-width: 260px; }
|
||||
|
||||
/* Labs textarea */
|
||||
.visit-labs, .lab-values {
|
||||
width: 100%; padding: 8px 12px; border: 1.5px solid var(--g300);
|
||||
border-radius: 8px; font-size: 13px; font-family: inherit;
|
||||
resize: vertical; margin-top: 8px; line-height: 1.5;
|
||||
}
|
||||
.visit-labs:focus, .lab-values:focus {
|
||||
outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-light);
|
||||
}
|
||||
|
||||
/* Admin Panel */
|
||||
.admin-stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;}
|
||||
.stat-card{flex:1;min-width:120px;background:white;border-radius:var(--radius);box-shadow:var(--shadow);padding:16px;text-align:center;}
|
||||
.stat-value{font-size:28px;font-weight:700;color:var(--blue);}
|
||||
.stat-label{font-size:12px;color:var(--g500);margin-top:4px;}
|
||||
.admin-table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
.admin-table th{text-align:left;padding:10px 16px;background:var(--g50);border-bottom:2px solid var(--g200);font-weight:600;color:var(--g600);font-size:12px;text-transform:uppercase;letter-spacing:0.4px;}
|
||||
.admin-table td{padding:10px 16px;border-bottom:1px solid var(--g100);vertical-align:middle;}
|
||||
.admin-table tr:last-child td{border-bottom:none;}
|
||||
.admin-table tr:hover td{background:var(--g50);}
|
||||
|
||||
/* ── WELL VISIT TAB ────────────────────────────────────────────────────────── */
|
||||
.wv-subtab-bar{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;}
|
||||
.wv-subtab-btn{padding:8px 18px;border:1.5px solid var(--g300);border-radius:8px;background:white;color:var(--g600);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:6px;transition:all 0.15s;}
|
||||
.wv-subtab-btn:hover{border-color:var(--blue);color:var(--blue);}
|
||||
.wv-subtab-btn.active{background:var(--blue);border-color:var(--blue);color:white;}
|
||||
|
||||
.wv-subpanel{animation:fadeIn 0.2s;}
|
||||
.wv-visit-selector-row{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap;}
|
||||
.wv-visit-selector-row label{font-weight:600;font-size:14px;color:var(--g700);display:flex;align-items:center;gap:6px;}
|
||||
.wv-visit-select{padding:8px 14px;border:1.5px solid var(--g300);border-radius:8px;font-size:14px;font-family:inherit;color:var(--g800);background:white;cursor:pointer;min-width:200px;}
|
||||
.wv-visit-select:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||
|
||||
.wv-visit-detail{display:flex;flex-direction:column;gap:14px;min-height:400px;}
|
||||
.wv-section{background:white;border-radius:var(--radius);box-shadow:var(--shadow);padding:16px 20px;}
|
||||
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
||||
.wv-section-title i{color:var(--blue);}
|
||||
|
||||
/* Billing */
|
||||
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
|
||||
.wv-billing-cell{display:flex;align-items:center;gap:8px;}
|
||||
.wv-label{font-size:12px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.5px;}
|
||||
.wv-code-chip{padding:4px 12px;border-radius:20px;font-size:13px;font-weight:700;font-family:monospace;}
|
||||
.wv-code-chip.icd{background:var(--blue-light);color:var(--blue-dark);}
|
||||
.wv-code-chip.cpt{background:var(--purple-light);color:var(--purple);}
|
||||
.wv-billing-desc{width:100%;font-size:13px;color:var(--g600);margin-top:4px;}
|
||||
|
||||
/* Chips */
|
||||
.wv-chip-list{display:flex;flex-wrap:wrap;gap:8px;}
|
||||
.wv-chip{padding:4px 12px;border-radius:20px;font-size:12px;font-weight:600;}
|
||||
.wv-chip-measure{background:var(--teal-light);color:var(--teal);}
|
||||
.wv-chip-range{background:var(--amber-light);color:#92400e;}
|
||||
|
||||
/* Vaccines */
|
||||
.wv-vax-list{display:flex;flex-direction:column;gap:10px;min-height:80px;}
|
||||
.wv-vax-item{background:var(--g50);border-radius:8px;padding:12px 16px;border-left:3px solid var(--green);}
|
||||
.wv-vax-name{font-size:14px;font-weight:600;color:var(--g800);display:flex;align-items:center;gap:8px;}
|
||||
.wv-vax-dot{color:var(--green);font-size:10px;}
|
||||
.wv-vax-dose{display:inline-block;margin-top:4px;font-size:11px;font-weight:700;padding:2px 10px;background:var(--green-light);color:#065f46;border-radius:10px;}
|
||||
.wv-vax-note{margin-top:6px;font-size:12px;color:var(--g500);display:flex;align-items:flex-start;gap:6px;}
|
||||
.wv-vax-note i{color:var(--blue);margin-top:2px;flex-shrink:0;}
|
||||
/* Vaccine status row */
|
||||
.wv-vax-status-row{display:flex;gap:4px;flex-wrap:wrap;margin-top:6px;}
|
||||
|
||||
/* Screens */
|
||||
.wv-screen-list{display:flex;flex-direction:column;gap:10px;min-height:60px;}
|
||||
.wv-screen-item{display:flex;align-items:center;gap:10px;}
|
||||
.wv-status-badge{flex-shrink:0;font-size:11px;font-weight:700;padding:2px 10px;border-radius:10px;text-transform:uppercase;letter-spacing:0.4px;}
|
||||
.wv-status-dot{background:var(--green-light);color:#065f46;}
|
||||
.wv-status-risk{background:var(--amber-light);color:#92400e;}
|
||||
.wv-status-range{background:var(--blue-light);color:var(--blue-dark);}
|
||||
.wv-screen-name{font-size:13px;color:var(--g700);}
|
||||
|
||||
/* Growth & Feeding */
|
||||
.wv-growth-box{display:flex;flex-direction:column;gap:8px;}
|
||||
.wv-growth-item{display:flex;gap:8px;align-items:baseline;font-size:13px;line-height:1.5;padding:6px 10px;background:var(--g50);border-radius:8px;}
|
||||
.wv-growth-label{font-weight:600;color:var(--g700);min-width:120px;white-space:nowrap;}
|
||||
.wv-feeding-list{margin:0;padding-left:20px;font-size:13px;line-height:1.7;color:var(--g700);}
|
||||
.wv-feeding-list li{margin-bottom:4px;}
|
||||
/* BMI Classification */
|
||||
.wv-bmi-table{display:flex;flex-direction:column;gap:6px;}
|
||||
.wv-bmi-row{display:grid;grid-template-columns:160px 1fr 1fr;gap:8px;padding:8px 12px;background:var(--g50);border-radius:8px;font-size:12px;align-items:center;}
|
||||
.wv-bmi-label{font-weight:700;font-size:13px;}
|
||||
.wv-bmi-range{color:var(--g600);}
|
||||
.wv-bmi-action{color:var(--g600);font-style:italic;}
|
||||
.wv-bmi-note{margin-top:8px;font-size:11px;color:#6b7280;display:flex;align-items:flex-start;gap:6px;}
|
||||
@media(max-width:768px){.wv-bmi-row{grid-template-columns:1fr;}}
|
||||
|
||||
/* Notes */
|
||||
.wv-notes-box{background:var(--blue-light);border-radius:8px;padding:12px 16px;font-size:13px;color:#1e40af;display:flex;align-items:flex-start;gap:8px;}
|
||||
.wv-empty,.wv-loading{color:var(--g400);font-size:14px;padding:20px 0;text-align:center;}
|
||||
|
||||
/* Catch-up */
|
||||
.wv-catchup-intro{background:var(--blue-light);border-radius:8px;padding:12px 16px;font-size:13px;color:#1e40af;display:flex;align-items:flex-start;gap:8px;margin-bottom:16px;}
|
||||
.wv-catchup-card{background:white;border-radius:var(--radius);box-shadow:var(--shadow);padding:16px 20px;margin-bottom:14px;}
|
||||
.wv-catchup-title{font-size:14px;font-weight:700;color:var(--g800);margin-bottom:10px;display:flex;align-items:center;gap:8px;}
|
||||
.wv-catchup-title i{color:var(--green);}
|
||||
.wv-catchup-min-age{font-size:13px;color:var(--g600);margin-bottom:10px;}
|
||||
.wv-catchup-table{width:100%;border-collapse:collapse;font-size:12px;margin-bottom:10px;}
|
||||
.wv-catchup-table th{text-align:left;padding:6px 10px;background:var(--g50);border-bottom:2px solid var(--g200);font-weight:600;color:var(--g600);font-size:11px;text-transform:uppercase;letter-spacing:0.4px;}
|
||||
.wv-catchup-table td{padding:6px 10px;border-bottom:1px solid var(--g100);vertical-align:top;}
|
||||
.wv-catchup-table tr:last-child td{border-bottom:none;}
|
||||
.wv-catchup-notes{margin:0;padding:0 0 0 18px;font-size:12px;color:var(--g600);line-height:1.7;}
|
||||
|
||||
/* Full schedule table */
|
||||
.wv-schedule-scroll{overflow-x:auto;border-radius:var(--radius);box-shadow:var(--shadow);}
|
||||
.wv-schedule-table{min-width:900px;width:100%;border-collapse:collapse;font-size:12px;background:white;}
|
||||
.wv-schedule-table th{text-align:center;padding:8px 6px;background:var(--g50);border-bottom:2px solid var(--g200);font-weight:700;color:var(--g600);font-size:11px;position:sticky;top:0;z-index:1;}
|
||||
.wv-sched-vax-col{text-align:left!important;min-width:200px;position:sticky;left:0;background:var(--g50)!important;z-index:2;}
|
||||
.wv-sched-age-col{min-width:70px;}
|
||||
.wv-sched-vax-name{padding:8px 10px;border-bottom:1px solid var(--g100);font-weight:600;color:var(--g700);position:sticky;left:0;background:white;z-index:1;}
|
||||
.wv-sched-cell{padding:6px 4px;border-bottom:1px solid var(--g100);text-align:center;color:var(--g400);}
|
||||
.wv-sched-has{background:var(--green-light);color:#065f46;font-weight:700;border-radius:4px;cursor:help;}
|
||||
.wv-sched-legend{font-size:11px;color:var(--g400);margin-top:10px;display:flex;align-items:center;gap:6px;}
|
||||
.wv-sched-leg-dot{display:inline-block;width:12px;height:12px;background:var(--green-light);border-radius:2px;}
|
||||
|
||||
/* Announcement Banner */
|
||||
.announcement-banner{display:flex;align-items:center;gap:10px;padding:10px 16px;font-size:13px;font-weight:500;border-bottom:1px solid transparent;}
|
||||
.announcement-banner.hidden{display:none;}
|
||||
.announcement-banner.ann-info{background:#eff6ff;color:#1d4ed8;border-color:#bfdbfe;}
|
||||
.announcement-banner.ann-warning{background:#fffbeb;color:#92400e;border-color:#fcd34d;}
|
||||
.announcement-banner.ann-error{background:#fef2f2;color:#991b1b;border-color:#fca5a5;}
|
||||
.announcement-banner.ann-success{background:#f0fdf4;color:#166534;border-color:#86efac;}
|
||||
.announcement-banner span{flex:1;}
|
||||
.announcement-close{background:none;border:none;cursor:pointer;opacity:0.6;font-size:15px;padding:6px 8px;color:inherit;line-height:1;border-radius:4px;}
|
||||
.announcement-close:hover{opacity:1;background:rgba(0,0,0,0.08);}
|
||||
|
||||
/* Save bar (encounter label + save/load) */
|
||||
.save-bar-wrap{position:relative;margin-bottom:10px;}
|
||||
.save-bar{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--g50);border:1px solid var(--g200);border-radius:var(--radius);flex-wrap:wrap;}
|
||||
.save-label-input{flex:1;min-width:160px;font-size:13px;padding:5px 8px;border:1px solid var(--g200);border-radius:6px;background:white;}
|
||||
.save-label-input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px rgba(37,99,235,.12);}
|
||||
|
||||
/* Saved encounters list */
|
||||
.saved-enc-item{display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--g50);border:1px solid var(--g200);border-radius:8px;font-size:13px;}
|
||||
.saved-enc-label{flex:1;font-weight:600;color:var(--g800);}
|
||||
.saved-enc-meta{font-size:11px;color:var(--g500);}
|
||||
.saved-enc-type{font-size:11px;background:var(--blue-light);color:var(--blue);padding:1px 6px;border-radius:10px;font-weight:600;}
|
||||
|
||||
/* Memory list item */
|
||||
.mem-item{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;background:var(--g50);border:1px solid var(--g200);border-radius:8px;}
|
||||
.mem-item-info{flex:1;}
|
||||
.mem-item-name{font-weight:600;font-size:13px;color:var(--g800);}
|
||||
.mem-item-cat{font-size:11px;background:var(--green-light);color:#166534;padding:1px 6px;border-radius:10px;font-weight:600;}
|
||||
.mem-item-preview{font-size:12px;color:var(--g500);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:320px;}
|
||||
|
||||
/* Models list in admin CMS */
|
||||
.model-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--g100);}
|
||||
.model-row:last-child{border-bottom:none;}
|
||||
.model-row-name{flex:1;font-size:13px;font-weight:500;color:var(--g800);}
|
||||
.model-row-cost{font-size:12px;color:var(--g500);}
|
||||
.model-row-tag{font-size:10px;background:var(--g100);color:var(--g600);padding:1px 5px;border-radius:8px;}
|
||||
.model-row-toggle{font-size:12px;}
|
||||
|
||||
/* SSHADESS form */
|
||||
.shadess-domain{margin-bottom:12px;padding:10px 12px;border-radius:8px;background:white;box-shadow:var(--shadow);}
|
||||
.shadess-domain-header{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;}
|
||||
.shadess-domain-body{display:flex;flex-direction:column;gap:6px;}
|
||||
.shadess-q-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:3px 0;}
|
||||
.shadess-q-text{font-size:13px;color:var(--g700);flex:1;min-width:200px;}
|
||||
.shadess-concern-badge{font-size:11px;background:var(--red-light);color:var(--red);padding:2px 8px;border-radius:10px;font-weight:600;}
|
||||
.shadess-flag-btn{font-size:11px;padding:2px 6px;}
|
||||
|
||||
/* Settings page */
|
||||
.settings-page{display:flex;flex-direction:column;gap:0;}
|
||||
.settings-page .settings-section{padding:20px;margin-bottom:12px;border-radius:var(--radius);}
|
||||
.settings-page .settings-section h3{font-size:16px;font-weight:700;color:var(--g800);margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid var(--g100);display:flex;align-items:center;gap:8px;}
|
||||
.settings-page .settings-section p{font-size:13px;color:var(--g600);margin-bottom:12px;line-height:1.6;}
|
||||
.settings-page .hipaa-info ul{margin:10px 0 10px 20px;font-size:13px;}
|
||||
.settings-page .hipaa-info li{margin-bottom:6px;line-height:1.5;}
|
||||
.settings-page .form-group{margin-bottom:14px;}
|
||||
.settings-page .form-group label{font-size:13px;font-weight:600;color:var(--g600);margin-bottom:4px;display:block;}
|
||||
.settings-page .form-group input{width:100%;padding:8px 12px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;box-sizing:border-box;}
|
||||
.settings-page .form-group input:focus{border-color:var(--blue);outline:none;box-shadow:0 0 0 2px var(--blue-light);}
|
||||
|
||||
/* Inline load popover — compact dropdown, right-aligned under Load button */
|
||||
.enc-load-popover{position:absolute;right:0;top:calc(100% + 4px);width:360px;max-width:calc(100vw - 24px);background:white;border:1px solid var(--g300);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.15);z-index:600;display:flex;flex-direction:column;}
|
||||
.enc-load-popover.hidden{display:none;}
|
||||
.enc-load-popover-inner{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--g200);}
|
||||
.enc-load-search{flex:1;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;}
|
||||
.enc-load-search:focus{outline:none;border-color:var(--blue);}
|
||||
.enc-pop-list{overflow-y:auto;max-height:260px;}
|
||||
.enc-pop-item{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;border-bottom:1px solid var(--g100);}
|
||||
.enc-pop-item:hover{background:var(--g50);}
|
||||
.enc-pop-item:last-child{border-bottom:none;}
|
||||
|
||||
/* ===== ROS / PE / DX — Tasks 2, 3, 4, 6 ===== */
|
||||
.ros-system-row{display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--g100);}
|
||||
.ros-system-row:last-child{border-bottom:none;}
|
||||
.ros-system-name{flex:1;font-size:13px;color:var(--g700);}
|
||||
.ros-status-btn{font-size:11px;padding:2px 8px;border-radius:12px;border:1px solid var(--g300);background:white;cursor:pointer;transition:all 0.15s;font-family:inherit;}
|
||||
.ros-status-btn:hover{background:var(--g100);}
|
||||
.ros-status-btn.wnl{background:var(--green-light);color:#166534;border-color:var(--green);}
|
||||
.ros-status-btn.abnormal{background:var(--red-light);color:#991b1b;border-color:var(--red);}
|
||||
.ros-status-btn.notrev{background:var(--g100);color:var(--g500);border-color:var(--g300);}
|
||||
.ros-note-input{font-size:12px;padding:3px 8px;border:1px solid var(--g300);border-radius:6px;width:180px;display:none;font-family:inherit;}
|
||||
.ros-note-input.visible{display:inline-block;}
|
||||
.dx-tag{display:inline-flex;align-items:center;gap:4px;background:var(--blue-light);color:#1d4ed8;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;margin:2px;}
|
||||
.dx-tag .dx-remove{cursor:pointer;opacity:0.7;font-size:14px;line-height:1;}
|
||||
.dx-tag .dx-remove:hover{opacity:1;}
|
||||
.dx-chip{cursor:pointer;padding:3px 10px;background:var(--g100);border:1px solid var(--g200);border-radius:12px;font-size:11px;color:var(--g700);display:inline-block;margin:2px;transition:all 0.15s;}
|
||||
.dx-chip:hover{background:var(--blue-light);color:var(--blue);border-color:var(--blue);}
|
||||
.sv-section-card{background:white;border-radius:var(--radius);border:1px solid var(--g200);padding:0;margin-bottom:10px;box-shadow:var(--shadow);}
|
||||
.sv-section-card .card-header{padding:10px 16px;border-bottom:1px solid var(--g100);display:flex;align-items:center;gap:8px;}
|
||||
.sv-section-card .card-header h3{font-size:14px;font-weight:600;color:var(--g800);display:flex;align-items:center;gap:6px;}
|
||||
.sv-section-card .card-body{padding:10px 16px;}
|
||||
.visit-status-btn{font-size:11px;padding:2px 8px;border-radius:10px;border:1px solid var(--g300);background:white;cursor:pointer;transition:all 0.15s;font-family:inherit;}
|
||||
.visit-status-btn.given,.visit-status-btn.done{background:var(--green-light);color:#166534;border-color:var(--green);}
|
||||
.visit-status-btn.refused{background:var(--red-light);color:#991b1b;border-color:var(--red);}
|
||||
.visit-status-btn.deferred,.visit-status-btn.not-due{background:var(--amber-light);color:#92400e;border-color:var(--amber);}
|
||||
.visit-status-btn.already-done{background:var(--blue-light);color:#1e40af;border-color:var(--blue);}
|
||||
@media(max-width:640px){
|
||||
.ros-system-row{flex-wrap:wrap;}
|
||||
.ros-note-input{width:100%;}
|
||||
.dx-chip{font-size:10px;}
|
||||
}
|
||||
|
||||
/* ICD-10 live search dropdown */
|
||||
.dx-results{position:absolute;top:calc(100% + 2px);left:0;right:0;background:white;border:1px solid var(--g300);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.12);z-index:400;max-height:240px;overflow-y:auto;}
|
||||
.dx-result-item{padding:7px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid var(--g100);}
|
||||
.dx-result-item:last-child{border-bottom:none;}
|
||||
.dx-result-item:hover{background:var(--g50);}
|
||||
.dx-result-code{font-weight:700;color:var(--blue);margin-right:4px;}
|
||||
|
||||
/* ============================================================
|
||||
LEARNING HUB
|
||||
============================================================ */
|
||||
|
||||
/* Category bar */
|
||||
.lh-category-bar{display:flex;gap:6px;flex-wrap:wrap;padding:8px 0;}
|
||||
.lh-cat-pill{padding:5px 14px;border-radius:20px;border:1px solid var(--g300);background:white;font-size:13px;cursor:pointer;transition:all 0.15s;font-family:inherit;color:var(--g600);}
|
||||
.lh-cat-pill:hover{border-color:var(--blue);color:var(--blue);}
|
||||
.lh-cat-pill.active{background:var(--blue);color:white;border-color:var(--blue);}
|
||||
|
||||
/* Feed */
|
||||
.lh-feed{display:flex;flex-direction:column;gap:8px;}
|
||||
.lh-feed-item{padding:14px 16px;cursor:pointer;transition:box-shadow 0.15s,border-color 0.15s;}
|
||||
.lh-feed-item:hover{box-shadow:var(--shadow-md);border-color:var(--blue-light);}
|
||||
.lh-feed-item-header{display:flex;align-items:flex-start;gap:12px;}
|
||||
.lh-feed-title{font-size:14px;font-weight:600;color:var(--g800);}
|
||||
.lh-feed-meta{font-size:12px;color:var(--g500);display:flex;gap:8px;flex-wrap:wrap;margin-top:2px;}
|
||||
.lh-feed-meta span:not(:last-child)::after{content:'·';margin-left:8px;color:var(--g300);}
|
||||
.lh-feed-badges{display:flex;gap:6px;flex-shrink:0;align-items:flex-start;}
|
||||
.lh-badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);white-space:nowrap;}
|
||||
.lh-badge-quiz{background:var(--amber-light);color:#92400e;}
|
||||
|
||||
/* Viewer */
|
||||
.lh-viewer .card{margin-bottom:0;}
|
||||
.lh-content-body{font-size:14px;line-height:1.75;color:var(--g700);}
|
||||
.lh-content-body h1,.lh-content-body h2,.lh-content-body h3{margin:16px 0 8px;color:var(--g800);}
|
||||
.lh-content-body p{margin-bottom:12px;}
|
||||
.lh-content-body ul,.lh-content-body ol{margin:0 0 12px 20px;}
|
||||
.lh-content-body li{margin-bottom:4px;}
|
||||
.lh-content-body strong{color:var(--g900);}
|
||||
.lh-content-body code{background:var(--g100);padding:2px 6px;border-radius:4px;font-size:13px;}
|
||||
.lh-content-body blockquote{border-left:3px solid var(--blue);padding:8px 16px;margin:12px 0;background:var(--blue-light);border-radius:0 6px 6px 0;font-style:italic;}
|
||||
.lh-content-body table{width:100%;border-collapse:collapse;margin:12px 0;}
|
||||
.lh-content-body th,.lh-content-body td{padding:8px 12px;border:1px solid var(--g200);font-size:13px;}
|
||||
.lh-content-body th{background:var(--g50);font-weight:600;}
|
||||
|
||||
/* Quiz — large, visual question cards */
|
||||
.lh-quiz-q{margin-bottom:24px;padding:20px;background:white;border:1.5px solid var(--g200);border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,0.04);}
|
||||
.lh-quiz-q:last-child{margin-bottom:0;}
|
||||
.lh-quiz-q-header{display:flex;gap:10px;align-items:center;margin-bottom:10px;}
|
||||
.lh-quiz-q-num{font-size:13px;font-weight:700;color:white;background:linear-gradient(135deg,var(--blue),var(--purple));padding:4px 12px;border-radius:8px;min-width:32px;text-align:center;}
|
||||
.lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;}
|
||||
.lh-quiz-q-text{font-size:16px;font-weight:600;margin-bottom:14px;color:var(--g800);line-height:1.5;}
|
||||
.lh-quiz-options{display:flex;flex-direction:column;gap:8px;}
|
||||
.lh-quiz-option{display:flex;align-items:center;gap:12px;padding:14px 18px;border:2px solid var(--g200);border-radius:10px;cursor:pointer;transition:all 0.2s;font-size:14px;line-height:1.4;background:white;}
|
||||
.lh-quiz-option:hover{border-color:var(--blue);background:var(--blue-light);transform:translateY(-1px);box-shadow:0 2px 8px rgba(37,99,235,0.1);}
|
||||
.lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
|
||||
.lh-quiz-option span{flex:1;}
|
||||
.lh-opt-correct{border-color:var(--green) !important;background:var(--green-light) !important;box-shadow:0 0 0 2px rgba(16,185,129,0.2) !important;}
|
||||
.lh-opt-wrong{border-color:var(--red) !important;background:var(--red-light) !important;box-shadow:0 0 0 2px rgba(239,68,68,0.2) !important;}
|
||||
|
||||
/* Quiz results */
|
||||
.lh-result-item{margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--g100);}
|
||||
.lh-result-item:last-child{border-bottom:none;}
|
||||
.lh-result-header{font-size:14px;margin-bottom:6px;}
|
||||
.lh-expl{font-size:13px;padding:8px 12px;border-radius:6px;margin-top:6px;}
|
||||
.lh-expl-wrong{background:var(--red-light);color:#991b1b;}
|
||||
.lh-expl-correct{background:var(--green-light);color:#166534;}
|
||||
.lh-expl-general{background:var(--blue-light);color:#1e40af;}
|
||||
|
||||
/* CMS question builder */
|
||||
.lh-question-block{padding:16px;border:1.5px solid var(--g200);border-radius:10px;margin-bottom:12px;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.04);transition:outline 0.2s;}
|
||||
|
||||
/* ============================================================
|
||||
CONTENT MANAGER (WordPress-like CMS)
|
||||
============================================================ */
|
||||
|
||||
/* Stats bar */
|
||||
.cms-stats-bar{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;}
|
||||
.cms-stat{flex:1;min-width:90px;background:white;border:1px solid var(--g200);border-radius:10px;padding:12px 16px;text-align:center;}
|
||||
.cms-stat-value{display:block;font-size:22px;font-weight:700;color:var(--blue);}
|
||||
.cms-stat-label{font-size:11px;color:var(--g500);text-transform:uppercase;letter-spacing:0.5px;}
|
||||
|
||||
/* Layout */
|
||||
.cms-layout{display:grid;grid-template-columns:220px 1fr;gap:16px;min-height:500px;}
|
||||
.cms-sidebar{display:flex;flex-direction:column;gap:12px;}
|
||||
.cms-sidebar-section{background:white;border:1px solid var(--g200);border-radius:10px;padding:14px;}
|
||||
.cms-sidebar-section h4{margin:0 0 10px;font-size:13px;color:var(--g600);display:flex;align-items:center;gap:6px;}
|
||||
.cms-main{background:white;border:1px solid var(--g200);border-radius:10px;overflow:visible;}
|
||||
|
||||
/* Category list in sidebar */
|
||||
.cms-cat-list{display:flex;flex-direction:column;gap:2px;margin-bottom:8px;}
|
||||
.cms-cat-item{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-radius:6px;font-size:13px;cursor:default;}
|
||||
.cms-cat-item:hover{background:var(--g50);}
|
||||
.cms-cat-item .cms-cat-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.cms-cat-item .cms-cat-count{font-size:11px;color:var(--g400);margin:0 6px;}
|
||||
.cms-add-cat{display:flex;gap:4px;}
|
||||
.cms-btn-add{width:32px;height:32px;border:1px solid var(--g300);border-radius:6px;background:white;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--blue);font-size:12px;}
|
||||
.cms-btn-add:hover{background:var(--blue-light);}
|
||||
|
||||
/* Input small */
|
||||
.cms-input-sm{font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;font-family:inherit;background:white;box-sizing:border-box;}
|
||||
.cms-input-sm:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-light);}
|
||||
|
||||
/* Toolbar */
|
||||
.cms-toolbar{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--g200);gap:12px;}
|
||||
.cms-search{display:flex;align-items:center;gap:6px;border:1px solid var(--g300);border-radius:6px;padding:0 10px;background:white;flex:1;max-width:280px;}
|
||||
.cms-search i{color:var(--g400);font-size:12px;}
|
||||
.cms-search input{border:none;outline:none;font-size:13px;padding:6px 0;width:100%;background:transparent;}
|
||||
|
||||
/* Content table */
|
||||
.cms-content-table{font-size:13px;}
|
||||
.cms-table-header{display:grid;grid-template-columns:1fr 110px 80px 80px 90px 40px;gap:8px;padding:8px 16px;background:var(--g50);border-bottom:1px solid var(--g200);font-weight:600;color:var(--g500);font-size:11px;text-transform:uppercase;letter-spacing:0.5px;}
|
||||
.cms-table-body{max-height:calc(100vh - 340px);overflow-y:auto;}
|
||||
.cms-table-row{display:grid;grid-template-columns:1fr 110px 80px 80px 90px 40px;gap:8px;padding:10px 16px;border-bottom:1px solid var(--g100);align-items:center;cursor:pointer;transition:background 0.1s;}
|
||||
.cms-table-row:hover{background:var(--g50);}
|
||||
.cms-col-title{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.cms-col-title .cms-title-sub{font-size:11px;color:var(--g400);font-weight:400;}
|
||||
.cms-col-cat,.cms-col-type,.cms-col-date{color:var(--g500);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.cms-col-status .cms-badge{font-size:11px;padding:2px 8px;border-radius:10px;font-weight:500;}
|
||||
.cms-badge-pub{background:var(--green-light);color:#166534;}
|
||||
.cms-badge-draft{background:var(--g100);color:var(--g500);}
|
||||
.cms-col-actions{text-align:center;}
|
||||
|
||||
/* Editor */
|
||||
.cms-editor{padding:16px 20px;}
|
||||
.cms-editor-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px;}
|
||||
.cms-editor-actions{display:flex;gap:8px;align-items:center;position:relative;z-index:2;}
|
||||
.cms-title-input{width:100%;font-size:22px;font-weight:600;padding:10px 0;border:none;border-bottom:2px solid var(--g200);outline:none;font-family:inherit;color:var(--g800);margin-bottom:12px;}
|
||||
.cms-title-input:focus{border-bottom-color:var(--blue);}
|
||||
.cms-meta-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px;}
|
||||
.cms-meta-field label{display:block;font-size:11px;font-weight:600;color:var(--g500);margin-bottom:3px;text-transform:uppercase;letter-spacing:0.3px;}
|
||||
.cms-meta-field select,.cms-meta-field input{width:100%;}
|
||||
.cms-label{display:block;font-size:12px;font-weight:600;color:var(--g600);margin-bottom:6px;}
|
||||
.cms-body-editor{width:100%;font-size:13px;line-height:1.6;padding:14px;border:1px solid var(--g300);border-radius:8px;resize:vertical;box-sizing:border-box;font-family:'Courier New',monospace;background:var(--g50);min-height:200px;}
|
||||
.cms-body-editor:focus{outline:none;border-color:var(--blue);background:white;box-shadow:0 0 0 2px var(--blue-light);}
|
||||
.cms-body-section{margin-bottom:20px;}
|
||||
|
||||
/* Quiz section in editor */
|
||||
.cms-quiz-section{border-top:2px solid var(--g200);padding-top:16px;}
|
||||
.cms-quiz-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;}
|
||||
.cms-quiz-header h3{margin:0;font-size:15px;color:var(--g700);}
|
||||
|
||||
/* Editor toolbar */
|
||||
.cms-editor-toolbar{display:flex;gap:2px;padding:6px 8px;background:var(--g50);border:1px solid var(--g300);border-bottom:none;border-radius:8px 8px 0 0;flex-wrap:wrap;}
|
||||
.cms-tb-btn{width:32px;height:28px;border:none;background:transparent;cursor:pointer;border-radius:4px;font-size:12px;color:var(--g600);display:flex;align-items:center;justify-content:center;font-family:inherit;font-weight:600;}
|
||||
.cms-tb-btn:hover{background:var(--g200);color:var(--g800);}
|
||||
.cms-tb-sep{width:1px;background:var(--g300);margin:2px 4px;align-self:stretch;}
|
||||
.cms-body-section .cms-body-editor{border-radius:0 0 8px 8px;}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media(max-width:768px){
|
||||
.cms-layout{grid-template-columns:1fr;}
|
||||
.cms-sidebar{flex-direction:row;overflow-x:auto;gap:8px;}
|
||||
.cms-sidebar-section{min-width:200px;flex-shrink:0;}
|
||||
.cms-table-header,.cms-table-row{grid-template-columns:1fr 80px 70px;font-size:12px;}
|
||||
.cms-col-cat,.cms-col-date,.cms-col-actions{display:none;}
|
||||
.cms-meta-row{grid-template-columns:1fr;gap:8px;}
|
||||
.cms-stats-bar{gap:4px;}
|
||||
.cms-stat{padding:8px 10px;}
|
||||
.cms-stat-value{font-size:18px;}
|
||||
}
|
||||
.lh-option-row{display:flex;align-items:flex-start;gap:8px;margin-bottom:6px;padding:6px 8px;border-radius:6px;background:var(--g50);}
|
||||
|
||||
@media(max-width:640px){
|
||||
.lh-feed-item-header{flex-direction:column;gap:6px;}
|
||||
.lh-feed-badges{align-self:flex-start;}
|
||||
.lh-option-row{flex-wrap:wrap;}
|
||||
.lh-option-row .lh-opt-expl{width:100%;}
|
||||
}
|
||||
|
||||
/* ── Tiptap editor ────────────────────────────────────────── */
|
||||
.cms-quill-body{border:1px solid var(--g300);border-radius:8px;background:white;overflow:hidden;}
|
||||
.cms-quill-body .tp-content .ProseMirror{min-height:280px;padding:16px;outline:none;font-size:14px;font-family:'Inter',system-ui,sans-serif;line-height:1.6;}
|
||||
|
||||
/* Shared ProseMirror content styles */
|
||||
.ProseMirror{outline:none;}
|
||||
.ProseMirror p{margin-bottom:8px;}
|
||||
.ProseMirror h2{font-size:20px;font-weight:700;margin:16px 0 8px;}
|
||||
.ProseMirror h3{font-size:16px;font-weight:600;margin:12px 0 6px;}
|
||||
.ProseMirror blockquote{border-left:4px solid var(--blue);padding-left:12px;color:var(--g600);margin:10px 0;}
|
||||
.ProseMirror pre{background:var(--g900);color:#e5e7eb;padding:12px;border-radius:6px;font-size:13px;overflow-x:auto;}
|
||||
.ProseMirror ul{padding-left:20px;margin-bottom:8px;}
|
||||
.ProseMirror ol{padding-left:20px;margin-bottom:8px;}
|
||||
.ProseMirror li{margin-bottom:3px;}
|
||||
.ProseMirror a{color:var(--blue);text-decoration:underline;}
|
||||
.ProseMirror code{background:var(--g100);padding:1px 5px;border-radius:3px;font-size:0.9em;}
|
||||
.ProseMirror p.is-editor-empty:first-child::before{content:attr(data-placeholder);color:var(--g400);pointer-events:none;float:left;height:0;}
|
||||
|
||||
/* Tiptap toolbar */
|
||||
.tp-toolbar{display:flex;flex-wrap:wrap;gap:2px;padding:6px 8px;background:var(--g50);border-bottom:1px solid var(--g200);}
|
||||
.tp-btn{height:28px;min-width:28px;padding:0 6px;border:none;background:transparent;cursor:pointer;border-radius:4px;font-size:12px;color:var(--g600);display:inline-flex;align-items:center;justify-content:center;font-family:inherit;font-weight:600;transition:background 0.1s;}
|
||||
.tp-btn:hover{background:var(--g200);color:var(--g800);}
|
||||
.tp-btn.active{background:var(--blue-light);color:var(--blue);}
|
||||
.tp-sep{width:1px;background:var(--g300);margin:2px 3px;align-self:stretch;}
|
||||
.tp-toolbar-mini{padding:4px 6px;}
|
||||
.tp-toolbar-mini .tp-btn{height:24px;min-width:24px;font-size:11px;}
|
||||
|
||||
/* Link bar — inline, no popup */
|
||||
.tp-link-bar{display:none;align-items:center;gap:6px;padding:6px 10px;background:#fffbeb;border-bottom:1px solid var(--amber);font-size:13px;}
|
||||
.tp-link-input{flex:1;padding:5px 10px;border:1px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;min-width:0;}
|
||||
.tp-link-apply{padding:4px 12px;background:var(--blue);color:white;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;}
|
||||
.tp-link-apply:hover{background:#1d4ed8;}
|
||||
.tp-link-remove{padding:4px 10px;background:var(--red-light);color:var(--red);border:1px solid var(--red);border-radius:6px;cursor:pointer;font-size:12px;}
|
||||
.tp-link-cancel{padding:4px 8px;background:transparent;border:none;cursor:pointer;color:var(--g400);font-size:14px;}
|
||||
.tp-content{position:relative;}
|
||||
|
||||
/* Mini richtext wraps for questions/options */
|
||||
.lh-q-richtext-wrap{border:1.5px solid var(--g300);border-radius:8px;overflow:hidden;margin-bottom:8px;}
|
||||
.lh-q-richtext-wrap .tp-content .ProseMirror{min-height:60px;padding:10px;font-size:14px;}
|
||||
.lh-opt-richtext-wrap{border:1.5px solid var(--g300);border-radius:6px;overflow:hidden;flex:2;}
|
||||
.lh-opt-richtext-wrap .tp-content .ProseMirror{min-height:36px;padding:6px 8px;font-size:13px;}
|
||||
|
||||
/* Rich text toggle button */
|
||||
.lh-richtext-btn{font-size:11px;padding:3px 8px;border:1px solid var(--g300);border-radius:4px;background:white;cursor:pointer;color:var(--g500);white-space:nowrap;}
|
||||
.lh-richtext-btn.active{background:var(--blue-light);border-color:var(--blue);color:var(--blue);font-weight:600;}
|
||||
|
||||
/* Multi-select quiz options */
|
||||
.lh-quiz-option input[type="checkbox"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
|
||||
.lh-quiz-multi-hint{font-size:12px;color:var(--g500);margin-bottom:10px;font-style:italic;}
|
||||
|
||||
/* ── Inline delete confirmation bar ──────────────────────── */
|
||||
/* display:flex is NOT set here — .hidden (display:none) must win */
|
||||
.lh-delete-confirm-bar{align-items:center;gap:10px;padding:10px 16px;background:#fef2f2;border:1.5px solid #fca5a5;border-radius:10px;margin-bottom:12px;font-size:13px;color:#991b1b;flex-wrap:wrap;}
|
||||
.lh-delete-confirm-bar:not(.hidden){display:flex;}
|
||||
.lh-delete-confirm-bar i{color:var(--red);flex-shrink:0;}
|
||||
|
||||
/* ── Marp / Presentation editor ──────────────────────────── */
|
||||
.lh-marp-textarea{width:100%;min-height:320px;font-family:'Courier New',monospace;font-size:13px;line-height:1.6;padding:14px;border:1px solid var(--g300);border-radius:8px;resize:vertical;box-sizing:border-box;background:var(--g900);color:#e5e7eb;outline:none;}
|
||||
.lh-marp-textarea:focus{border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-light);}
|
||||
|
||||
/* ── Slide preview modal ──────────────────────────────────── */
|
||||
.slide-preview-modal{position:fixed;inset:0;z-index:9000;display:flex;align-items:center;justify-content:center;}
|
||||
.slide-preview-modal.hidden{display:none;}
|
||||
.slide-preview-overlay{position:absolute;inset:0;background:rgba(0,0,0,0.85);}
|
||||
.slide-preview-shell{position:relative;z-index:1;width:100%;height:100%;display:flex;flex-direction:column;padding:12px;}
|
||||
.slide-preview-topbar{display:flex;align-items:center;justify-content:space-between;padding:0 0 10px;flex-shrink:0;}
|
||||
.slide-preview-counter{font-size:14px;font-weight:700;color:white;background:rgba(255,255,255,0.15);padding:4px 14px;border-radius:20px;}
|
||||
.slide-preview-close{background:rgba(255,255,255,0.15);border:none;color:white;padding:6px 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;transition:background 0.15s;}
|
||||
.slide-preview-close:hover{background:rgba(255,255,255,0.25);}
|
||||
.slide-preview-stage{flex:1;display:flex;align-items:center;justify-content:center;gap:12px;min-height:0;overflow:hidden;}
|
||||
.slide-preview-frame{flex:1;max-width:900px;background:white;border-radius:8px;overflow:auto;box-shadow:0 8px 40px rgba(0,0,0,0.6);max-height:100%;display:flex;flex-direction:column;}
|
||||
.slide-preview-frame section{all:unset;display:block;padding:40px 48px !important;box-sizing:border-box;}
|
||||
.slide-preview-frame section *{box-sizing:border-box;}
|
||||
.slide-nav-btn{background:rgba(255,255,255,0.15);border:none;color:white;font-size:36px;line-height:1;padding:10px 14px;border-radius:10px;cursor:pointer;flex-shrink:0;transition:background 0.15s;user-select:none;}
|
||||
.slide-nav-btn:hover{background:rgba(255,255,255,0.25);}
|
||||
.slide-preview-dots{display:flex;justify-content:center;gap:6px;padding-top:10px;flex-shrink:0;flex-wrap:wrap;}
|
||||
.slide-dot{width:10px;height:10px;border-radius:50%;border:none;background:rgba(255,255,255,0.3);cursor:pointer;padding:0;transition:background 0.15s;}
|
||||
.slide-dot.active{background:white;}
|
||||
@media(max-width:600px){
|
||||
.slide-nav-btn{font-size:24px;padding:8px 10px;}
|
||||
.slide-preview-topbar span:nth-child(2){display:none;}
|
||||
}
|
||||
|
||||
/* ── AI Generate panel ────────────────────────────────────── */
|
||||
.lh-ai-panel{border:2px solid transparent;background:linear-gradient(white,white) padding-box,linear-gradient(135deg,#7c3aed,#2563eb) border-box;border-radius:12px;padding:20px;margin-bottom:16px;}
|
||||
.lh-ai-panel-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;}
|
||||
.lh-ai-tabs{display:flex;gap:4px;margin-bottom:14px;border-bottom:1px solid var(--g200);padding-bottom:10px;}
|
||||
.lh-ai-tab{padding:6px 14px;border:1px solid var(--g200);border-radius:8px;background:white;cursor:pointer;font-size:13px;font-weight:500;color:var(--g600);display:flex;align-items:center;gap:6px;transition:all 0.15s;}
|
||||
.lh-ai-tab:hover{border-color:var(--blue);color:var(--blue);}
|
||||
.lh-ai-tab.active{background:var(--blue-light);border-color:var(--blue);color:var(--blue);font-weight:600;}
|
||||
.lh-ai-tabpanel{margin-bottom:14px;}
|
||||
.lh-ai-dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px;border:2px dashed var(--g300);border-radius:10px;cursor:pointer;text-align:center;font-size:13px;color:var(--g600);transition:border-color 0.2s;background:var(--g50);}
|
||||
.lh-ai-dropzone:hover{border-color:var(--blue);background:var(--blue-light);}
|
||||
.lh-ai-options{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;padding:14px;background:var(--g50);border-radius:8px;}
|
||||
.lh-ai-opt-row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;}
|
||||
.lh-ai-opt-field{display:flex;flex-direction:column;gap:3px;}
|
||||
.lh-ai-opt-field label{font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;}
|
||||
|
||||
/* Quiz questions card inside AI panel */
|
||||
.lh-ai-quiz-card{background:linear-gradient(135deg,#eff6ff,#eef2ff);border:1.5px solid #c7d2fe;border-radius:10px;overflow:hidden;}
|
||||
.lh-ai-quiz-card-header{display:flex;align-items:center;gap:8px;padding:10px 14px;background:white;border-bottom:1px solid #e0e7ff;}
|
||||
.lh-ai-quiz-card-body{padding:12px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:12px;}
|
||||
|
||||
/* WebDAV browser */
|
||||
.lh-webdav-item{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:13px;cursor:pointer;border-bottom:1px solid var(--g100);transition:background 0.1s;}
|
||||
.lh-webdav-item:last-child{border-bottom:none;}
|
||||
.lh-webdav-item:hover{background:var(--g50);}
|
||||
.lh-webdav-dir{color:var(--g700);font-weight:500;}
|
||||
.lh-webdav-file{color:var(--g600);}
|
||||
.lh-webdav-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- E2E test harness — loads the calculators component + JS in isolation
|
||||
(no auth wall). Playwright points here instead of the full SPA.
|
||||
All scripts are external because the app's CSP blocks inline scripts. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>E2E harness — calculators</title>
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; background: #f9fafb; }
|
||||
#calculators-tab { max-width: 1100px; margin: 0 auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section id="calculators-tab" class="tab-content active" data-component="calculators"></section>
|
||||
<!-- Bedside used to live inside calculators; since the promotion to a top-level
|
||||
tab, the harness now renders both components side-by-side so the bedside
|
||||
smoke tests can find #bedside-age without having to click the old
|
||||
calc-nav-pill (which no longer exists). -->
|
||||
<section id="bedside-tab" class="tab-content active" data-component="bedside"></section>
|
||||
|
||||
<script defer src="/js/calc-math.js"></script>
|
||||
<script defer src="/js/drugs-loader.js"></script>
|
||||
<script defer src="/js/calculators.js"></script>
|
||||
<script type="module" src="/js/bedside/index.js"></script>
|
||||
<script defer src="/js/e2e-bootstrap.js"></script>
|
||||
|
||||
<!-- Lightbox overlay — global in the real app, mirrored here so the
|
||||
bedside lightbox smoke test can open/close it. -->
|
||||
<div id="img-lightbox" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:20px;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px;color:white;max-width:95%;">
|
||||
<div id="img-lightbox-title" style="flex:1;font-weight:600;font-size:14px;"></div>
|
||||
<a id="img-lightbox-open" href="#" target="_blank" rel="noopener" style="color:#93c5fd;font-size:13px;"><i class="fas fa-up-right-from-square"></i> Open in new tab</a>
|
||||
<button id="img-lightbox-close" type="button" style="background:rgba(255,255,255,0.15);color:white;border:none;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:14px;">Close</button>
|
||||
</div>
|
||||
<img id="img-lightbox-img" src="" alt="" style="max-width:95%;max-height:85vh;object-fit:contain;border-radius:6px;background:white;">
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pediatric AI Scribe</title>
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
|
||||
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
|
||||
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="PedScribe">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512.png">
|
||||
<!-- Auth screen hidden by default — auth.js shows it only when no valid session exists -->
|
||||
<style>#auth-screen { display: none; }</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- AUTH SCREEN -->
|
||||
<div id="auth-screen" class="auth-screen">
|
||||
<div class="auth-box">
|
||||
<div class="auth-logo">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<h1>Pediatric AI Scribe</h1>
|
||||
<p>AI-Powered Clinical Documentation</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form id="login-form" class="auth-form" autocomplete="off">
|
||||
<h2>Sign In</h2>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="login-email" required placeholder="your@email.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" id="login-password" required placeholder="••••••••">
|
||||
</div>
|
||||
<div id="totp-group" class="form-group hidden">
|
||||
<label>2FA Code</label>
|
||||
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
|
||||
</div>
|
||||
<div class="cf-turnstile" id="turnstile-login" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
|
||||
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
|
||||
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
|
||||
</div>
|
||||
<a href="/api/auth/oidc" id="btn-sso" class="btn-auth" style="display:none;text-align:center;text-decoration:none;background:linear-gradient(135deg,#0f172a,#334155);margin-top:0;">
|
||||
<i class="fas fa-shield-halved"></i> <span id="sso-label">Sign in with SSO</span>
|
||||
</a>
|
||||
<div id="resend-verify-box" class="hidden" style="margin:12px 0;padding:12px;background:#fef3c7;border-radius:8px;text-align:center;">
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#92400e;">Your email is not verified yet.</p>
|
||||
<a href="#" id="resend-verify-link" style="font-size:13px;font-weight:600;color:#2563eb;">Resend verification link</a>
|
||||
</div>
|
||||
<div class="auth-links">
|
||||
<a href="#" id="show-register" style="display:none">Create account</a>
|
||||
<a href="#" id="show-forgot">Forgot password?</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Register Form -->
|
||||
<form id="register-form" class="auth-form hidden">
|
||||
<h2>Create Account</h2>
|
||||
<div class="form-group">
|
||||
<label>Full Name</label>
|
||||
<input type="text" id="reg-name" required placeholder="Dr. Jane Smith">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="reg-email" required placeholder="your@email.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password (8+ characters)</label>
|
||||
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||
<button type="submit" class="btn-auth">Create Account</button>
|
||||
<div class="auth-links">
|
||||
<a href="#" id="show-login">Back to sign in</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<form id="forgot-form" class="auth-form hidden">
|
||||
<h2>Reset Password</h2>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="forgot-email" required placeholder="your@email.com">
|
||||
</div>
|
||||
<div class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||
<button type="submit" class="btn-auth">Send Reset Link</button>
|
||||
<div class="auth-links">
|
||||
<a href="#" id="show-login-2">Back to sign in</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="hipaa-notice">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
<span>HIPAA-compliant AI providers available with BAA. Check with your institution's guidelines before use. Not intended for production clinical use without proper authorization.</span>
|
||||
</div>
|
||||
|
||||
<div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;">
|
||||
<a href="https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;">
|
||||
<i class="fas fa-mobile-screen"></i> Download Android app (APK)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<details style="margin:16px 0 0;text-align:left;max-width:480px;margin-left:auto;margin-right:auto;">
|
||||
<summary style="cursor:pointer;font-size:13px;font-weight:600;color:#64748b;text-align:center;">About Pediatric AI Scribe</summary>
|
||||
<div style="padding:12px 0;font-size:12px;color:#64748b;line-height:1.7;">
|
||||
<p style="margin:0 0 8px;">AI-powered clinical documentation for pediatric medicine. Generate HPIs, SOAP notes, hospital courses, chart reviews, well/sick visit notes, and developmental milestone assessments from voice or text.</p>
|
||||
<p style="margin:0 0 8px;">Includes pediatric calculators (BP percentile, BMI, growth charts, bilirubin nomograms, vital signs), Learning Hub with quizzes and AI-generated content, and comprehensive security (2FA, SSO, session management, audit logging).</p>
|
||||
<p style="margin:0;">All AI providers support BAA for HIPAA compliance. Audio and encounters auto-expire. AI learns from your edits over time.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<footer class="app-footer">
|
||||
Committed to healthcare equity — always consult a qualified healthcare professional for medical advice.
|
||||
•
|
||||
© 2024-2026 Pediatric AI Scribe by PedsHub. All rights reserved.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- MAIN APP (hidden until authenticated) -->
|
||||
<div id="main-app" class="hidden">
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="header-top">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<button id="btn-menu-toggle" class="btn-header btn-menu-toggle" title="Menu" aria-label="Toggle menu">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="logo">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<div>
|
||||
<h1>Pediatric AI Scribe</h1>
|
||||
<p class="tagline">Welcome, <span id="user-name">Doctor</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="model-selector">
|
||||
<label><i class="fas fa-robot"></i></label>
|
||||
<select id="global-model-select"></select>
|
||||
<span id="model-cost-badge" class="cost-badge" style="display:none;"></span>
|
||||
</div>
|
||||
<div class="header-buttons">
|
||||
<button id="btn-settings" class="btn-header" title="Settings">
|
||||
<i class="fas fa-cog"></i>
|
||||
</button>
|
||||
<button id="btn-logout" class="btn-header" title="Logout">
|
||||
<i class="fas fa-right-from-bracket"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ANNOUNCEMENT BANNER -->
|
||||
<div id="announcement-banner" class="announcement-banner hidden" role="alert">
|
||||
<i id="announcement-icon" class="fas fa-info-circle"></i>
|
||||
<span id="announcement-text"></span>
|
||||
<button id="announcement-close" class="announcement-close" aria-label="Dismiss"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="app-body">
|
||||
|
||||
<!-- SIDEBAR NAVIGATION -->
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title"><i class="fas fa-stethoscope"></i> Menu</span>
|
||||
<button id="btn-sidebar-close" class="btn-sm btn-ghost" title="Close menu" style="padding:4px 8px;">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-nav">
|
||||
<span class="sidebar-section-label">Encounters</span>
|
||||
<button class="tab-btn active" data-tab="encounter">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span>Encounter HPI</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="dictation">
|
||||
<i class="fas fa-microphone"></i>
|
||||
<span>Dictation HPI</span>
|
||||
</button>
|
||||
<span class="sidebar-section-label">Notes</span>
|
||||
<button class="tab-btn" data-tab="hospital">
|
||||
<i class="fas fa-hospital"></i>
|
||||
<span>Hospital Course</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="chart">
|
||||
<i class="fas fa-clipboard-list"></i>
|
||||
<span>Chart Review</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="soap">
|
||||
<i class="fas fa-file-waveform"></i>
|
||||
<span>SOAP Note</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="wellvisit">
|
||||
<i class="fas fa-calendar-check"></i>
|
||||
<span>Well Visit</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="sickvisit">
|
||||
<i class="fas fa-thermometer-half"></i>
|
||||
<span>Sick Visit</span>
|
||||
</button>
|
||||
<span class="sidebar-section-label">Clinical Tools</span>
|
||||
<button class="tab-btn" data-tab="vaxschedule">
|
||||
<i class="fas fa-table"></i>
|
||||
<span>Vaccine Schedule</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="catchup">
|
||||
<i class="fas fa-rotate"></i>
|
||||
<span>Catch-Up Schedule</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="peguide">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<span>Physical Exam Guide</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="bedside">
|
||||
<i class="fas fa-briefcase-medical" style="color:var(--red);"></i>
|
||||
<span>Bedside</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="calculators">
|
||||
<i class="fas fa-calculator"></i>
|
||||
<span>Calculators</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="extensions">
|
||||
<i class="fas fa-phone-volume"></i>
|
||||
<span>Pagers & Extensions</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="learning">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
<span>Learning Hub</span>
|
||||
</button>
|
||||
<button class="tab-btn hidden" data-tab="cms" id="cms-tab-btn">
|
||||
<i class="fas fa-pen-to-square"></i>
|
||||
<span>Content Manager</span>
|
||||
</button>
|
||||
<span class="sidebar-section-label">Account</span>
|
||||
<button class="tab-btn" data-tab="settings">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="faq">
|
||||
<i class="fas fa-circle-question"></i>
|
||||
<span>FAQ</span>
|
||||
</button>
|
||||
<button class="tab-btn hidden" data-tab="admin" id="admin-tab-btn">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Desktop collapse button (hidden on mobile) -->
|
||||
<button id="btn-sidebar-pin" class="sidebar-pin-btn" title="Collapse sidebar">
|
||||
<i class="fas fa-angles-left"></i>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Desktop sidebar expand button (shown when sidebar is collapsed) -->
|
||||
<button id="btn-sidebar-expand" class="sidebar-expand-btn" title="Expand sidebar" style="display:none;">
|
||||
<i class="fas fa-angles-right"></i>
|
||||
</button>
|
||||
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div id="busy-bar" class="busy-bar">
|
||||
<div class="busy-spinner"></div>
|
||||
<span class="busy-text" id="busy-text">Processing...</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Tab components (lazy-loaded from /components/*.html) -->
|
||||
<section id="encounter-tab" class="tab-content active" data-component="encounter"></section>
|
||||
<section id="dictation-tab" class="tab-content" data-component="dictation"></section>
|
||||
<section id="hospital-tab" class="tab-content" data-component="hospital"></section>
|
||||
<section id="chart-tab" class="tab-content" data-component="chart"></section>
|
||||
<section id="soap-tab" class="tab-content" data-component="soap"></section>
|
||||
<section id="vaxschedule-tab" class="tab-content" data-component="vaxschedule"></section>
|
||||
<section id="catchup-tab" class="tab-content" data-component="catchup"></section>
|
||||
<section id="peguide-tab" class="tab-content" data-component="pe-guide"></section>
|
||||
<section id="bedside-tab" class="tab-content" data-component="bedside"></section>
|
||||
<section id="calculators-tab" class="tab-content" data-component="calculators"></section>
|
||||
<section id="extensions-tab" class="tab-content" data-component="extensions"></section>
|
||||
<section id="learning-tab" class="tab-content" data-component="learning"></section>
|
||||
<section id="cms-tab" class="tab-content" data-component="cms"></section>
|
||||
<section id="admin-tab" class="tab-content" data-component="admin"></section>
|
||||
<section id="wellvisit-tab" class="tab-content" data-component="wellvisit"></section>
|
||||
<section id="sickvisit-tab" class="tab-content" data-component="sickvisit"></section>
|
||||
<section id="settings-tab" class="tab-content" data-component="settings"></section>
|
||||
<section id="faq-tab" class="tab-content" data-component="faq"></section>
|
||||
|
||||
</main>
|
||||
|
||||
</div><!-- /.app-body -->
|
||||
|
||||
<footer style="padding:12px 24px;text-align:center;font-size:12px;color:var(--g400);border-top:1px solid var(--g100);">
|
||||
<a href="#" id="btn-show-about" style="color:var(--g500);text-decoration:none;">About Pediatric AI Scribe</a>
|
||||
•
|
||||
© <span id="app-year"></span> PedsHub. All rights reserved.
|
||||
</footer>
|
||||
|
||||
<!-- About Modal -->
|
||||
<div id="about-modal" class="confirm-modal hidden">
|
||||
<div class="confirm-modal-box" style="max-width:600px;max-height:80vh;overflow-y:auto;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2 style="margin:0;font-size:18px;color:var(--g800);"><i class="fas fa-stethoscope" style="color:var(--blue);margin-right:8px;"></i>Pediatric AI Scribe</h2>
|
||||
<button id="btn-close-about" class="btn-sm btn-ghost" style="padding:4px 8px;"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<p style="color:var(--g600);line-height:1.7;font-size:13px;margin:0 0 14px;">An AI-powered clinical documentation platform designed specifically for pediatric medicine. Built to help physicians spend less time on documentation and more time with patients.</p>
|
||||
|
||||
<h4 style="font-size:13px;color:var(--blue-dark);margin:16px 0 8px;">Clinical Documentation</h4>
|
||||
<ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;">
|
||||
<li><strong>Live Encounter</strong> — Record doctor-patient conversations, AI generates structured HPI using OLDCARTS framework</li>
|
||||
<li><strong>Voice Dictation</strong> — Dictate your narrative, AI restructures into clean clinical documentation</li>
|
||||
<li><strong>SOAP Notes</strong> — Full SOAP or subjective-only from transcript or dictation</li>
|
||||
<li><strong>Hospital Course</strong> — Prose, day-by-day, organ-system (ICU), or psych format from progress notes</li>
|
||||
<li><strong>Chart Review</strong> — Summarize outpatient, subspecialty, and ED visits for precharting</li>
|
||||
<li><strong>Well Visit</strong> — AAP Bright Futures with SSHADESS, milestones, vaccines, and billing codes</li>
|
||||
<li><strong>Sick Visit</strong> — Quick documentation with auto-suggested ROS and PE</li>
|
||||
</ul>
|
||||
|
||||
<h4 style="font-size:13px;color:var(--blue-dark);margin:16px 0 8px;">Pediatric Calculators</h4>
|
||||
<ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;">
|
||||
<li><strong>BP Percentile</strong> — AAP 2017 with Rosner spline regression, height-adjusted</li>
|
||||
<li><strong>BMI Percentile</strong> — CDC 2000 with extended obesity classification and visual chart</li>
|
||||
<li><strong>Growth Charts</strong> — Visual WHO/CDC/Fenton curves with patient plotting and mid-parental height</li>
|
||||
<li><strong>Bilirubin</strong> — AAP 2022 phototherapy thresholds and Bhutani nomogram</li>
|
||||
<li><strong>Vital Signs</strong> — Harriet Lane reference with quick formulas (ETT, fluids, weight estimation)</li>
|
||||
<li><strong>BSA & Dosing</strong> — Mosteller BSA and weight-based dose calculator with volume</li>
|
||||
</ul>
|
||||
|
||||
<h4 style="font-size:13px;color:var(--blue-dark);margin:16px 0 8px;">AI & Speech</h4>
|
||||
<ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;">
|
||||
<li>Multiple AI providers with HIPAA-compliant options (BAA-covered)</li>
|
||||
<li>Multiple transcription engines including medical-specific models</li>
|
||||
<li>Browser Whisper for fully offline, private transcription</li>
|
||||
<li>Text-to-speech for reading notes aloud</li>
|
||||
<li>AI learns from your edits over time (correction tracking)</li>
|
||||
<li>Customizable AI prompts for each note type</li>
|
||||
</ul>
|
||||
|
||||
<h4 style="font-size:13px;color:var(--blue-dark);margin:16px 0 8px;">Learning Hub</h4>
|
||||
<ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;">
|
||||
<li>Educational articles, clinical pearls, quizzes, and slide presentations</li>
|
||||
<li>AI-powered content generation from topics, PDFs, or Nextcloud files</li>
|
||||
<li>Marp presentations with PPTX export</li>
|
||||
<li>Semantic search powered by vector embeddings</li>
|
||||
</ul>
|
||||
|
||||
<h4 style="font-size:13px;color:var(--blue-dark);margin:16px 0 8px;">Security & Privacy</h4>
|
||||
<ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;">
|
||||
<li>Two-factor authentication (TOTP) and SSO/OIDC support</li>
|
||||
<li>Session management with device tracking and revocation</li>
|
||||
<li>Comprehensive audit logging of all clinical actions</li>
|
||||
<li>Auto-expiring data (encounters 7 days, audio 24 hours)</li>
|
||||
<li>Cloudflare Turnstile bot protection</li>
|
||||
<li>Role-based access control (admin, moderator, user)</li>
|
||||
</ul>
|
||||
|
||||
<p style="font-size:11px;color:var(--g400);margin:16px 0 0;text-align:center;">AI-generated notes should always be reviewed by a qualified healthcare professional before clinical use.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- dummy settings-modal kept for any stale JS references -->
|
||||
<div id="settings-modal" class="hidden" style="display:none !important;"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div id="confirm-modal" class="confirm-modal hidden">
|
||||
<div class="confirm-modal-box">
|
||||
<p id="confirm-modal-text"></p>
|
||||
<div id="confirm-modal-input-wrap" style="display:none;margin:10px 0;">
|
||||
<input type="text" id="confirm-modal-input" style="width:100%;padding:8px;border:1px solid var(--g300);border-radius:6px;font-size:13px;">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px;">
|
||||
<button id="confirm-modal-cancel" class="btn-sm btn-ghost">Cancel</button>
|
||||
<button id="confirm-modal-ok" class="btn-sm btn-primary">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOADING -->
|
||||
<div id="loading-overlay" class="loading-overlay hidden">
|
||||
<div class="loading-box"><div class="spinner"></div><p id="loading-text">Processing...</p></div>
|
||||
</div>
|
||||
|
||||
<!-- TOAST -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<!-- SLIDE PREVIEW MODAL -->
|
||||
<div id="slide-preview-modal" class="slide-preview-modal hidden" role="dialog" aria-modal="true">
|
||||
<div class="slide-preview-overlay" id="slide-preview-overlay"></div>
|
||||
<div class="slide-preview-shell">
|
||||
<div class="slide-preview-topbar">
|
||||
<span class="slide-preview-counter" id="slide-preview-counter">1 / 1</span>
|
||||
<span style="font-size:13px;color:rgba(255,255,255,0.6);">Use arrow keys or swipe to navigate</span>
|
||||
<button class="slide-preview-close" id="slide-preview-close" aria-label="Close preview">✕ Close</button>
|
||||
</div>
|
||||
<div class="slide-preview-stage" id="slide-preview-stage">
|
||||
<button class="slide-nav-btn slide-nav-prev" id="slide-nav-prev" aria-label="Previous slide">‹</button>
|
||||
<div class="slide-preview-frame" id="slide-preview-frame">
|
||||
<style id="slide-preview-css"></style>
|
||||
<div id="slide-preview-content"></div>
|
||||
</div>
|
||||
<button class="slide-nav-btn slide-nav-next" id="slide-nav-next" aria-label="Next slide">›</button>
|
||||
</div>
|
||||
<div class="slide-preview-dots" id="slide-preview-dots"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPTS (defer for faster initial paint) -->
|
||||
<script src="/vendor/tiptap.bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"
|
||||
integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
||||
<script defer src="/js/milestonesData.js"></script>
|
||||
<script defer src="/js/pediatricScheduleData.js"></script>
|
||||
<script defer src="/js/audioBackup.js"></script>
|
||||
<script defer src="/js/correctionTracker.js"></script>
|
||||
<script defer src="/js/browserWhisper.js"></script>
|
||||
<script defer src="/js/speechRecognition.js"></script>
|
||||
<script defer src="/js/transcriptionSettings.js"></script>
|
||||
<script defer src="/js/voicePreferences.js"></script>
|
||||
<script defer src="/js/app.js"></script>
|
||||
<script defer src="/js/ui-state.js"></script>
|
||||
<script defer src="/js/secureStorage.js"></script>
|
||||
<script defer src="/js/authFetch.js"></script>
|
||||
<script defer src="/js/auth.js"></script>
|
||||
<script defer src="/js/liveEncounter.js"></script>
|
||||
<script defer src="/js/voiceDictation.js"></script>
|
||||
<script defer src="/js/hospitalCourse.js"></script>
|
||||
<script defer src="/js/chartReview.js"></script>
|
||||
<script defer src="/js/soap.js"></script>
|
||||
<script defer src="/js/milestones.js"></script>
|
||||
<script defer src="/js/peGuide.js"></script>
|
||||
<script defer src="/js/extensions.js"></script>
|
||||
<script defer src="/js/nextcloud.js"></script>
|
||||
<script defer src="/js/wellVisit.js"></script>
|
||||
<script defer src="/js/shadess.js"></script>
|
||||
<script defer src="/js/sickVisit.js"></script>
|
||||
<script defer src="/js/encounters.js"></script>
|
||||
<script defer src="/js/memories.js"></script>
|
||||
<script defer src="/js/documents.js"></script>
|
||||
<script defer src="/js/calc-math.js"></script>
|
||||
<script defer src="/js/drugs-loader.js"></script>
|
||||
<script defer src="/js/calculators.js"></script>
|
||||
<script type="module" src="/js/bedside/index.js"></script>
|
||||
<script defer src="/js/learningHub.js"></script>
|
||||
<script defer src="/js/admin.js"></script>
|
||||
|
||||
<!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any
|
||||
[data-img-src] button in any tab; lives here so it exists in the
|
||||
DOM from page load, independent of which tab has been lazy-loaded
|
||||
yet). Styled inline to keep the CSP-compliant "no inline scripts"
|
||||
rule (inline styles are allowed). ═══════════ -->
|
||||
<div id="img-lightbox" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:20px;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px;color:white;max-width:95%;">
|
||||
<div id="img-lightbox-title" style="flex:1;font-weight:600;font-size:14px;"></div>
|
||||
<a id="img-lightbox-open" href="#" target="_blank" rel="noopener" style="color:#93c5fd;font-size:13px;text-decoration:none;"><i class="fas fa-up-right-from-square"></i> Open in new tab</a>
|
||||
<button id="img-lightbox-close" type="button" style="background:rgba(255,255,255,0.15);color:white;border:none;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:14px;"><i class="fas fa-xmark"></i> Close</button>
|
||||
</div>
|
||||
<img id="img-lightbox-img" src="" alt="" style="max-width:95%;max-height:85vh;object-fit:contain;border-radius:6px;box-shadow:0 4px 30px rgba(0,0,0,0.5);background:white;">
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1533
public/js/admin.js
1533
public/js/admin.js
File diff suppressed because it is too large
Load diff
990
public/js/app.js
990
public/js/app.js
|
|
@ -1,990 +0,0 @@
|
|||
// ============================================================
|
||||
// APP.JS — Core utilities, tabs, model selector, helpers
|
||||
// ============================================================
|
||||
|
||||
// ── Client-side error logging ──────────────────────────────
|
||||
(function() {
|
||||
function sendError(data) {
|
||||
try {
|
||||
navigator.sendBeacon('/api/logs/client-error', new Blob(
|
||||
[JSON.stringify(data)], { type: 'application/json' }
|
||||
));
|
||||
} catch(e) {}
|
||||
}
|
||||
window.onerror = function(msg, src, line, col, err) {
|
||||
// Ignore errors from browser extensions
|
||||
if (src && src.indexOf('moz-extension') !== -1) return;
|
||||
if (src && src.indexOf('chrome-extension') !== -1) return;
|
||||
sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack });
|
||||
};
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
|
||||
sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack });
|
||||
});
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
|
||||
var _componentCache = {};
|
||||
var _componentLoading = {};
|
||||
|
||||
function loadComponent(tabEl) {
|
||||
var component = tabEl.getAttribute('data-component');
|
||||
if (!component || tabEl.dataset.loaded) return Promise.resolve();
|
||||
if (_componentCache[component]) {
|
||||
tabEl.innerHTML = _componentCache[component];
|
||||
tabEl.dataset.loaded = '1';
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (_componentLoading[component]) return _componentLoading[component];
|
||||
|
||||
_componentLoading[component] = fetch('/components/' + component + '.html')
|
||||
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||
.then(function(html) {
|
||||
_componentCache[component] = html;
|
||||
tabEl.innerHTML = html;
|
||||
tabEl.dataset.loaded = '1';
|
||||
// Re-attach per-tab model selectors
|
||||
tabEl.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
||||
if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel);
|
||||
});
|
||||
delete _componentLoading[component];
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn('[Component] Failed to load ' + component + ':', err);
|
||||
tabEl.innerHTML = '<div style="padding:40px;text-align:center;color:var(--g400);">Failed to load. Please refresh.</div>';
|
||||
delete _componentLoading[component];
|
||||
});
|
||||
return _componentLoading[component];
|
||||
}
|
||||
|
||||
// Preload the first tab immediately
|
||||
var firstTab = document.querySelector('.tab-content[data-component]');
|
||||
if (firstTab) loadComponent(firstTab);
|
||||
|
||||
// --- TAB NAVIGATION ---
|
||||
function activateTab(tabName) {
|
||||
var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
|
||||
if (!btn || btn.classList.contains('hidden')) return false;
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
var tabEl = document.getElementById(tabName + '-tab');
|
||||
if (tabEl) {
|
||||
tabEl.classList.add('active');
|
||||
// Lazy-load component HTML, then fire tabChanged after DOM is ready
|
||||
loadComponent(tabEl).then(function() {
|
||||
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
|
||||
});
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
|
||||
}
|
||||
// Persist last tab in localStorage (restored after login in auth.js)
|
||||
try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {}
|
||||
// Close sidebar on mobile after tab click
|
||||
var sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expose activateTab globally so auth.js can call it after login
|
||||
window.activateTab = activateTab;
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
activateTab(btn.getAttribute('data-tab'));
|
||||
var subtab = btn.getAttribute('data-subtab');
|
||||
if (subtab && typeof window.wvSwitchSubtab === 'function') {
|
||||
setTimeout(function() { window.wvSwitchSubtab(subtab); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- SIDEBAR TOGGLE ---
|
||||
var sidebar = document.getElementById('sidebar');
|
||||
var sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-menu-toggle')) {
|
||||
if (sidebar) sidebar.classList.toggle('open');
|
||||
}
|
||||
if (e.target.closest('#btn-sidebar-close') || e.target.closest('#sidebar-overlay')) {
|
||||
if (sidebar) sidebar.classList.remove('open');
|
||||
}
|
||||
});
|
||||
if (sidebarOverlay) {
|
||||
sidebarOverlay.addEventListener('click', function() {
|
||||
if (sidebar) sidebar.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
// --- DESKTOP SIDEBAR COLLAPSE ---
|
||||
var sidebarPinBtn = document.getElementById('btn-sidebar-pin');
|
||||
var sidebarExpandBtn = document.getElementById('btn-sidebar-expand');
|
||||
// Restore collapse state
|
||||
try {
|
||||
if (localStorage.getItem('ped_sidebar_collapsed') === '1') {
|
||||
if (sidebar) sidebar.classList.add('collapsed');
|
||||
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex';
|
||||
if (sidebarPinBtn) sidebarPinBtn.style.display = 'none';
|
||||
}
|
||||
} catch(e) {}
|
||||
if (sidebarPinBtn) {
|
||||
sidebarPinBtn.addEventListener('click', function() {
|
||||
if (sidebar) sidebar.classList.add('collapsed');
|
||||
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex';
|
||||
if (sidebarPinBtn) sidebarPinBtn.style.display = 'none';
|
||||
try { localStorage.setItem('ped_sidebar_collapsed', '1'); } catch(e) {}
|
||||
});
|
||||
}
|
||||
if (sidebarExpandBtn) {
|
||||
sidebarExpandBtn.addEventListener('click', function() {
|
||||
if (sidebar) sidebar.classList.remove('collapsed');
|
||||
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'none';
|
||||
if (sidebarPinBtn) sidebarPinBtn.style.display = 'flex';
|
||||
try { localStorage.setItem('ped_sidebar_collapsed', '0'); } catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// Set footer year
|
||||
var yearEl = document.getElementById('app-year');
|
||||
if (yearEl) yearEl.textContent = new Date().getFullYear();
|
||||
|
||||
// Load settings data when settings tab is activated
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail && e.detail.tab === 'settings') {
|
||||
if (typeof load2FAStatus === 'function') load2FAStatus();
|
||||
if (typeof loadSessions === 'function') loadSessions();
|
||||
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
|
||||
if (typeof loadMemories === 'function') loadMemories();
|
||||
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
|
||||
if (typeof renderAudioBackups === 'function') renderAudioBackups();
|
||||
if (typeof loadDocuments === 'function') loadDocuments();
|
||||
initBrowserWhisperSettings();
|
||||
}
|
||||
if (e.detail && e.detail.tab === 'faq') {
|
||||
// Wire FAQ accordion after component loads
|
||||
setTimeout(function() {
|
||||
document.querySelectorAll('.faq-question').forEach(function(btn) {
|
||||
if (btn._faqWired) return;
|
||||
btn._faqWired = true;
|
||||
btn.addEventListener('click', function() {
|
||||
var item = btn.parentElement;
|
||||
var isOpen = item.classList.contains('open');
|
||||
var section = item.closest('.faq-section');
|
||||
if (section) section.querySelectorAll('.faq-item.open').forEach(function(i) { i.classList.remove('open'); });
|
||||
if (!isOpen) item.classList.add('open');
|
||||
});
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Browser Whisper settings UI ───────────────────────────
|
||||
function initBrowserWhisperSettings() {
|
||||
var chk = document.getElementById('browser-whisper-enabled');
|
||||
var sel = document.getElementById('browser-whisper-model');
|
||||
var pre = document.getElementById('btn-whisper-preload');
|
||||
var stat = document.getElementById('browser-whisper-status');
|
||||
var prog = document.getElementById('browser-whisper-progress');
|
||||
var pt = document.getElementById('browser-whisper-progress-text');
|
||||
var sec = document.getElementById('browser-whisper-section');
|
||||
if (!chk) return;
|
||||
|
||||
var supported = typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isSupported();
|
||||
if (!supported) {
|
||||
if (sec) sec.innerHTML += '<p style="color:var(--red);font-size:12px;margin:8px 0 0;">Not supported in this browser. Use Chrome or Edge.</p>';
|
||||
if (chk) chk.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore saved state
|
||||
chk.checked = BrowserWhisper.isEnabled();
|
||||
sel.value = BrowserWhisper.getModel();
|
||||
stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off';
|
||||
|
||||
chk.addEventListener('change', function() {
|
||||
BrowserWhisper.setEnabled(chk.checked);
|
||||
stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off';
|
||||
if (chk.checked) {
|
||||
BrowserWhisper.preload(function(file, pct) {
|
||||
if (!prog || !pt) return;
|
||||
if (pct >= 100) { prog.style.display = 'none'; return; }
|
||||
prog.style.display = 'block';
|
||||
pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sel.addEventListener('change', function() {
|
||||
BrowserWhisper.setModel(sel.value);
|
||||
});
|
||||
|
||||
if (pre) {
|
||||
pre.addEventListener('click', function(e) {
|
||||
console.log('[BrowserWhisper] Pre-download button clicked!');
|
||||
e.preventDefault();
|
||||
|
||||
if (!prog || !pt) {
|
||||
console.error('[BrowserWhisper] Progress elements not found');
|
||||
showToast('UI elements missing - check page load', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BrowserWhisper || !BrowserWhisper.isSupported()) {
|
||||
console.error('[BrowserWhisper] Not supported');
|
||||
showToast('Browser Whisper not supported in this browser', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[BrowserWhisper] Starting preload...');
|
||||
prog.style.display = 'block';
|
||||
pt.textContent = 'Initializing...';
|
||||
|
||||
BrowserWhisper.setEnabled(true);
|
||||
chk.checked = true;
|
||||
stat.textContent = 'On — audio stays on device';
|
||||
|
||||
// Set timeout in case it gets stuck
|
||||
var timeout = setTimeout(function() {
|
||||
console.warn('[BrowserWhisper] 30s elapsed - still downloading, check Network tab');
|
||||
showToast('Download in progress - check browser console', 'info');
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
BrowserWhisper.preload(function(file, pct) {
|
||||
console.log('[BrowserWhisper] Progress:', file, pct + '%');
|
||||
if (pct >= 100) {
|
||||
clearTimeout(timeout);
|
||||
prog.style.display = 'none';
|
||||
showToast('Whisper model ready!', 'success');
|
||||
return;
|
||||
}
|
||||
prog.style.display = 'block';
|
||||
pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
console.error('[BrowserWhisper] Preload error:', err);
|
||||
prog.style.display = 'none';
|
||||
pt.textContent = '';
|
||||
|
||||
// Show CSP/network warning
|
||||
var cspWarning = document.getElementById('browser-whisper-csp-warning');
|
||||
if (cspWarning) cspWarning.style.display = 'block';
|
||||
|
||||
showToast('Download blocked by network/firewall. Server transcription will be used.', 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- MODEL SELECTOR ---
|
||||
var modelSelect = document.getElementById('global-model-select');
|
||||
var costBadge = document.getElementById('model-cost-badge');
|
||||
window._currentModels = [];
|
||||
window._currentProvider = 'openrouter';
|
||||
|
||||
fetch('/api/models')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
window._currentModels = data.models || [];
|
||||
window._currentProvider = data.provider || 'openrouter';
|
||||
|
||||
window._buildModelOptions = function buildModelOptions(selectEl) {
|
||||
selectEl.innerHTML = '';
|
||||
window._currentModels.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Determine default model (admin override or first model)
|
||||
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
|
||||
|
||||
if (modelSelect && window._currentModels.length > 0) {
|
||||
window._buildModelOptions(modelSelect);
|
||||
// Select the admin-configured default
|
||||
if (defaultModelId) modelSelect.value = defaultModelId;
|
||||
if (costBadge) costBadge.textContent = '';
|
||||
}
|
||||
|
||||
// Populate all per-tab model selectors already in DOM
|
||||
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
||||
window._buildModelOptions(sel);
|
||||
if (defaultModelId) sel.value = defaultModelId;
|
||||
});
|
||||
})
|
||||
.catch(function(err) { console.warn('Models load failed:', err); });
|
||||
|
||||
if (modelSelect) {
|
||||
modelSelect.addEventListener('change', function() {
|
||||
var m = window._currentModels.find(function(x) { return x.id === modelSelect.value; });
|
||||
showToast('Model: ' + modelSelect.value.split('/').pop(), 'info');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ App.js DOM ready');
|
||||
|
||||
}); // end DOMContentLoaded
|
||||
|
||||
// ============================================================
|
||||
// GLOBAL FUNCTIONS (must be outside DOMContentLoaded)
|
||||
// ============================================================
|
||||
|
||||
// ── Set formatted text on output/contenteditable elements ──
|
||||
// Converts \n to <br> so line breaks survive in contenteditable divs
|
||||
// and are preserved when copying with innerText
|
||||
function setOutputText(el, text) {
|
||||
if (!el) return;
|
||||
if (typeof el === 'string') el = document.getElementById(el);
|
||||
if (!el) return;
|
||||
// Escape HTML, then convert newlines to <br>
|
||||
var safe = String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
el.innerHTML = safe.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// ── Data-action event delegation (replaces inline onclick handlers) ──
|
||||
// Handles data-action="copy|speak|nc-export" on any element,
|
||||
// allowing removal of 'unsafe-inline' from the Content Security Policy.
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
var action = btn.getAttribute('data-action');
|
||||
var targetId = btn.getAttribute('data-target');
|
||||
if (!action) return;
|
||||
|
||||
if (action === 'copy' && targetId) {
|
||||
if (typeof copyText === 'function') copyText(targetId);
|
||||
// Log PHI copy event (fire-and-forget, best-effort)
|
||||
try {
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
if (token) {
|
||||
fetch('/api/logs/client-event', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||
body: JSON.stringify({ action: 'copy_to_clipboard', target: targetId })
|
||||
}).catch(function() {});
|
||||
}
|
||||
} catch(e) {}
|
||||
return;
|
||||
}
|
||||
if (action === 'speak' && targetId) {
|
||||
if (typeof speakText === 'function') speakText(targetId);
|
||||
return;
|
||||
}
|
||||
if (action === 'nc-export' && targetId) {
|
||||
var label = btn.getAttribute('data-label') || 'export';
|
||||
if (typeof exportToNextcloud === 'function') exportToNextcloud(targetId, label);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Announcement banner ────────────────────────────────────
|
||||
function loadAnnouncement() {
|
||||
fetch('/api/admin/config/announcement', { headers: getAuthHeaders() })
|
||||
.then(function(r) { if (!r.ok) throw new Error('not ok'); return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var banner = document.getElementById('announcement-banner');
|
||||
var text = document.getElementById('announcement-text');
|
||||
var icon = document.getElementById('announcement-icon');
|
||||
if (!banner || !text) return;
|
||||
if (data.enabled && data.text && data.text.trim()) {
|
||||
text.textContent = data.text;
|
||||
// type → icon + class
|
||||
var icons = { info: 'fa-info-circle', warning: 'fa-triangle-exclamation', error: 'fa-circle-xmark', success: 'fa-circle-check' };
|
||||
var type = data.type || 'info';
|
||||
if (icon) { icon.className = 'fas ' + (icons[type] || icons.info); }
|
||||
banner.className = 'announcement-banner ann-' + type;
|
||||
} else {
|
||||
banner.className = 'announcement-banner hidden';
|
||||
}
|
||||
})
|
||||
.catch(function() {}); // silently ignore if endpoint not yet available
|
||||
}
|
||||
|
||||
// Close button — dismiss for this page view only (reappears on refresh/re-login)
|
||||
(function() {
|
||||
var closeBtn = document.getElementById('announcement-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function() {
|
||||
var banner = document.getElementById('announcement-banner');
|
||||
if (banner) banner.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function getSelectedModel() {
|
||||
// Prefer the active tab's own model selector if present
|
||||
var activeTab = document.querySelector('.tab-content.active');
|
||||
if (activeTab) {
|
||||
var tabSel = activeTab.querySelector('.tab-model-select');
|
||||
if (tabSel && tabSel.value) return tabSel.value;
|
||||
}
|
||||
var sel = document.getElementById('global-model-select');
|
||||
return sel ? sel.value : 'google/gemini-2.5-flash';
|
||||
}
|
||||
|
||||
function showLoading(text) {
|
||||
var overlay = document.getElementById('loading-overlay');
|
||||
var textEl = document.getElementById('loading-text');
|
||||
if (textEl) textEl.textContent = text || 'Processing...';
|
||||
if (overlay) { overlay.style.display = 'flex'; overlay.className = overlay.className.replace('hidden', '').trim(); }
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
var overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// Non-blocking busy bar — user can keep working while AI processes
|
||||
function showBusy(text) {
|
||||
var bar = document.getElementById('busy-bar');
|
||||
var textEl = document.getElementById('busy-text');
|
||||
if (textEl) textEl.textContent = text || 'Processing...';
|
||||
if (bar) bar.classList.add('active');
|
||||
}
|
||||
|
||||
function hideBusy() {
|
||||
var bar = document.getElementById('busy-bar');
|
||||
if (bar) bar.classList.remove('active');
|
||||
}
|
||||
|
||||
// Reusable confirmation modal — replaces browser confirm() and prompt()
|
||||
// Usage: showConfirm('Are you sure?', function() { doSomething(); });
|
||||
// With input: showConfirm('Enter email:', function(value) { send(value); }, { input: true, placeholder: 'email@example.com', inputType: 'email' });
|
||||
// With danger styling: showConfirm('Delete user?', function() { deleteUser(); }, { danger: true, confirmText: 'Delete' });
|
||||
window.showConfirm = function(message, onConfirm, opts) {
|
||||
opts = opts || {};
|
||||
var modal = document.getElementById('confirm-modal');
|
||||
var text = document.getElementById('confirm-modal-text');
|
||||
var okBtn = document.getElementById('confirm-modal-ok');
|
||||
var cancelBtn = document.getElementById('confirm-modal-cancel');
|
||||
var inputWrap = document.getElementById('confirm-modal-input-wrap');
|
||||
var inputEl = document.getElementById('confirm-modal-input');
|
||||
if (!modal || !text || !okBtn) return;
|
||||
|
||||
text.textContent = message;
|
||||
okBtn.textContent = opts.confirmText || 'Confirm';
|
||||
if (opts.danger) { okBtn.style.background = 'var(--red)'; okBtn.style.borderColor = 'var(--red)'; }
|
||||
else { okBtn.style.background = ''; okBtn.style.borderColor = ''; }
|
||||
|
||||
if (opts.input) {
|
||||
inputWrap.style.display = 'block';
|
||||
inputEl.type = opts.inputType || 'text';
|
||||
inputEl.placeholder = opts.placeholder || '';
|
||||
inputEl.value = opts.defaultValue || '';
|
||||
setTimeout(function() { inputEl.focus(); }, 50);
|
||||
} else {
|
||||
inputWrap.style.display = 'none';
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
function cleanup() {
|
||||
modal.classList.add('hidden');
|
||||
okBtn.onclick = null;
|
||||
cancelBtn.onclick = null;
|
||||
inputEl.value = '';
|
||||
}
|
||||
|
||||
cancelBtn.onclick = function() { cleanup(); };
|
||||
okBtn.onclick = function() {
|
||||
if (opts.input) {
|
||||
var val = inputEl.value;
|
||||
if (opts.required && !val.trim()) { showToast(opts.requiredMsg || 'This field is required', 'error'); return; }
|
||||
cleanup();
|
||||
if (onConfirm) onConfirm(val);
|
||||
} else {
|
||||
cleanup();
|
||||
if (onConfirm) onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
// Allow Enter key to confirm
|
||||
inputEl.onkeydown = function(e) { if (e.key === 'Enter') okBtn.click(); };
|
||||
};
|
||||
|
||||
function showToast(message, type) {
|
||||
var container = document.getElementById('toast-container');
|
||||
if (!container) { console.log('Toast:', type, message); return; }
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + (type || 'success');
|
||||
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
|
||||
var iconEl = document.createElement('i');
|
||||
iconEl.className = 'fas fa-' + icon;
|
||||
toast.appendChild(iconEl);
|
||||
toast.appendChild(document.createTextNode(' ' + String(message == null ? '' : message)));
|
||||
container.appendChild(toast);
|
||||
setTimeout(function() { toast.remove(); }, 3500);
|
||||
}
|
||||
|
||||
function copyText(elementId) {
|
||||
var el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
var text = el.innerText || el.textContent || '';
|
||||
if (!text.trim()) { showToast('Nothing to copy', 'error'); return; }
|
||||
|
||||
// Try modern clipboard API (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showToast('Copied!', 'success');
|
||||
}).catch(function(err) {
|
||||
// Fallback: select + execCommand
|
||||
try {
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
window.getSelection().removeAllRanges();
|
||||
window.getSelection().addRange(range);
|
||||
var ok = document.execCommand('copy');
|
||||
window.getSelection().removeAllRanges();
|
||||
showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error');
|
||||
} catch (e) {
|
||||
showToast('Copy not supported in this browser', 'error');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No clipboard API — use execCommand directly
|
||||
try {
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
window.getSelection().removeAllRanges();
|
||||
window.getSelection().addRange(range);
|
||||
var ok = document.execCommand('copy');
|
||||
window.getSelection().removeAllRanges();
|
||||
showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error');
|
||||
} catch (e) {
|
||||
showToast('Copy not supported in this browser', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Speak / Stop
|
||||
var currentlyReadingId = null;
|
||||
var currentAudio = null;
|
||||
|
||||
function speakText(elementId) {
|
||||
if (currentlyReadingId === elementId) { stopReading(); return; }
|
||||
stopReading();
|
||||
var el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
var text = (el.innerText || el.textContent).trim();
|
||||
if (!text) { showToast('Nothing to read', 'error'); return; }
|
||||
|
||||
currentlyReadingId = elementId;
|
||||
var btn = findReadButton(elementId);
|
||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...'; }
|
||||
|
||||
fetch('/api/text-to-speech', {
|
||||
method: 'POST',
|
||||
headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text })
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('TTS request failed (' + r.status + ')');
|
||||
var ttsProvider = r.headers.get('X-TTS-Provider') || 'server';
|
||||
return r.blob().then(function(blob) { return { blob: blob, provider: ttsProvider }; });
|
||||
})
|
||||
.then(function(result) {
|
||||
var url = URL.createObjectURL(result.blob);
|
||||
currentAudio = new Audio(url);
|
||||
currentAudio.onended = function() { URL.revokeObjectURL(url); stopReading(); };
|
||||
currentAudio.onerror = function() { URL.revokeObjectURL(url); stopReading(); showToast('Audio playback error', 'error'); };
|
||||
currentAudio.play();
|
||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||
showToast('Reading aloud (' + result.provider + ')', 'info');
|
||||
})
|
||||
.catch(function(err) {
|
||||
stopReading();
|
||||
// Fallback to browser TTS if server TTS fails
|
||||
if ('speechSynthesis' in window) {
|
||||
var utter = new SpeechSynthesisUtterance(text);
|
||||
utter.rate = 0.9;
|
||||
utter.onend = function() { stopReading(); };
|
||||
currentlyReadingId = elementId;
|
||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||
window.speechSynthesis.speak(utter);
|
||||
showToast('TTS unavailable — using browser voice', 'info');
|
||||
} else {
|
||||
showToast('Read aloud failed: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopReading() {
|
||||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
|
||||
if (currentlyReadingId) {
|
||||
var btn = findReadButton(currentlyReadingId);
|
||||
if (btn) { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-high"></i> Read'; }
|
||||
currentlyReadingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function findReadButton(elementId) {
|
||||
var el = document.getElementById(elementId);
|
||||
if (!el) return null;
|
||||
var card = el.closest('.output-card') || el.closest('.card');
|
||||
if (!card) return null;
|
||||
var buttons = card.querySelectorAll('button');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
var btn = buttons[i];
|
||||
// data-action="speak" buttons (current approach)
|
||||
if (btn.getAttribute('data-action') === 'speak' && btn.getAttribute('data-target') === elementId) return btn;
|
||||
// legacy onclick fallback
|
||||
var oc = btn.getAttribute('onclick') || '';
|
||||
if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return btn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Timer
|
||||
function createTimer(el) {
|
||||
var s = 0, iv = null;
|
||||
return {
|
||||
start: function() { s = 0; this.resume(); },
|
||||
resume: function() { this.update(); var self = this; if (!iv) iv = setInterval(function() { s++; self.update(); }, 1000); },
|
||||
stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; },
|
||||
reset: function() { s = 0; this.update(); },
|
||||
update: function() { el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); }
|
||||
};
|
||||
}
|
||||
|
||||
// Audio Recorder
|
||||
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
|
||||
AudioRecorder.prototype.start = function() {
|
||||
var self = this; self.chunks = [];
|
||||
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } })
|
||||
.then(function(stream) {
|
||||
self.stream = stream;
|
||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
// 32kbps Opus is excellent for speech — small files, fast upload, great quality
|
||||
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
|
||||
self.mediaRecorder.start(1000);
|
||||
});
|
||||
};
|
||||
AudioRecorder.prototype.stop = function() {
|
||||
var self = this;
|
||||
return new Promise(function(resolve) {
|
||||
if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') { resolve(null); return; }
|
||||
self.mediaRecorder.onstop = function() {
|
||||
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
|
||||
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
|
||||
resolve(blob);
|
||||
};
|
||||
self.mediaRecorder.stop();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Native mobile helpers (Capacitor / Android bridge) ──
|
||||
// These only activate when running inside the native app
|
||||
window.nativeHaptic = function(style) {
|
||||
try {
|
||||
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
|
||||
window.Capacitor.Plugins.Haptics.impact({ style: style || 'medium' });
|
||||
} else if (navigator.vibrate) {
|
||||
navigator.vibrate(style === 'heavy' ? 100 : 50);
|
||||
}
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
// Start/stop Android foreground service for background recording
|
||||
window.nativeStartRecordingService = function() {
|
||||
try { if (window.NativeRecording) window.NativeRecording.startForegroundService(); } catch(e) {}
|
||||
};
|
||||
window.nativeStopRecordingService = function() {
|
||||
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
|
||||
};
|
||||
|
||||
// Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap)
|
||||
window.nativeKeepAwake = function(on) {
|
||||
try {
|
||||
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
|
||||
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
|
||||
else window.Capacitor.Plugins.KeepAwake.allowSleep();
|
||||
}
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
// Detect if running in native app
|
||||
window.isNativeApp = function() {
|
||||
return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
|
||||
};
|
||||
|
||||
// Check if server-side transcription (Whisper/AWS) is available
|
||||
window._transcribeAvailable = null; // null = not checked yet, true/false after check
|
||||
function checkTranscribeStatus() {
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
if (!token) return;
|
||||
fetch('/api/transcribe/status', {
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
window._transcribeAvailable = !!data.available;
|
||||
window._transcribeProvider = data.provider || 'none';
|
||||
if (!data.available) {
|
||||
console.log('[Transcribe] No server transcription configured — using browser speech recognition only');
|
||||
}
|
||||
})
|
||||
.catch(function() { window._transcribeAvailable = false; });
|
||||
}
|
||||
|
||||
function transcribeAudio(blob) {
|
||||
// Browser Whisper — local, zero network, HIPAA-safe
|
||||
if (typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isEnabled()) {
|
||||
var startTime = Date.now();
|
||||
return BrowserWhisper.transcribe(blob)
|
||||
.then(function(text) {
|
||||
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
showToast('Transcribed locally (' + elapsed + 's)', 'success');
|
||||
window._lastAudioBackupId = null;
|
||||
return { success: true, text: text, provider: 'browser-whisper' };
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn('[BrowserWhisper] Failed:', err.message, '— falling back to server');
|
||||
if (typeof saveAudioBackup === 'function') saveAudioBackup(blob, 'browser-whisper-failed').catch(function() {});
|
||||
return _serverTranscribe(blob);
|
||||
});
|
||||
}
|
||||
return _serverTranscribe(blob);
|
||||
}
|
||||
|
||||
function _serverTranscribe(blob) {
|
||||
// If no server transcription is configured, skip upload entirely
|
||||
if (window._transcribeAvailable === false) {
|
||||
return Promise.resolve({ success: false, noProvider: true, error: 'No transcription API configured — using live transcript' });
|
||||
}
|
||||
|
||||
var startTime = Date.now();
|
||||
var formData = new FormData();
|
||||
formData.append('audio', blob, 'audio.webm');
|
||||
return fetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
|
||||
body: formData
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
if (data.success && data.provider) {
|
||||
showToast('Transcribed via ' + data.provider + ' (' + elapsed + 's)', 'info');
|
||||
}
|
||||
if (!data.success && blob.size > 0 && typeof saveAudioBackup === 'function') {
|
||||
console.log('[AudioBackup] Transcription failed, saving backup...');
|
||||
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
|
||||
console.log('[AudioBackup] Saved with id:', id);
|
||||
showToast('Audio backed up for retry', 'info');
|
||||
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
|
||||
}
|
||||
return data;
|
||||
}).catch(function(err) {
|
||||
if (blob.size > 0 && typeof saveAudioBackup === 'function') {
|
||||
console.log('[AudioBackup] Transcription error, saving backup...');
|
||||
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
|
||||
console.log('[AudioBackup] Saved with id:', id);
|
||||
showToast('Audio backed up for retry', 'info');
|
||||
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
|
||||
}
|
||||
return { success: false, error: err.message };
|
||||
});
|
||||
}
|
||||
|
||||
// Store original source material on an output element (call after generation)
|
||||
function storeSourceContext(outputElementId, sourceText) {
|
||||
var el = document.getElementById(outputElementId);
|
||||
if (el && sourceText) el.dataset.sourceContext = sourceText;
|
||||
}
|
||||
|
||||
// ── Billing code suggestions (called after note generation) ──
|
||||
function suggestBillingCodes(outputElementId, noteText, noteType, patientAge, visitType) {
|
||||
// Find or create the billing codes container near the output element
|
||||
var outputEl = document.getElementById(outputElementId);
|
||||
if (!outputEl || !noteText) return;
|
||||
var card = outputEl.closest('.card, .output-card');
|
||||
if (!card) return;
|
||||
|
||||
var containerId = outputElementId.replace('-text', '') + '-billing-codes';
|
||||
var container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
// Create container dynamically if not in HTML
|
||||
container = document.createElement('div');
|
||||
container.id = containerId;
|
||||
container.className = 'billing-codes-card';
|
||||
// Insert after the output text element
|
||||
outputEl.parentNode.insertBefore(container, outputEl.nextSibling);
|
||||
}
|
||||
|
||||
container.className = 'billing-codes-card';
|
||||
container.innerHTML = '<div style="font-size:12px;color:var(--g500);"><i class="fas fa-spinner fa-spin"></i> Analyzing billing codes...</div>';
|
||||
|
||||
fetch('/api/suggest-codes', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
noteText: noteText,
|
||||
noteType: noteType || 'soap',
|
||||
patientAge: patientAge || '',
|
||||
visitType: visitType || 'outpatient'
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success || (!data.icd10.length && !data.cpt.length)) {
|
||||
container.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<h4><i class="fas fa-file-invoice-dollar"></i> Suggested Billing Codes</h4>';
|
||||
|
||||
if (data.icd10 && data.icd10.length > 0) {
|
||||
html += '<div class="billing-codes-section"><div class="billing-codes-label">ICD-10 Diagnoses</div><div>';
|
||||
data.icd10.forEach(function(c) {
|
||||
var name = c.name ? ' <span class="billing-code-name">' + escHtml(c.name) + '</span>' : '';
|
||||
html += '<span class="billing-code-chip icd" title="Click to copy" data-code="' + escHtml(c.code) + '">' + escHtml(c.code) + name + '</span>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
if (data.cpt && data.cpt.length > 0) {
|
||||
html += '<div class="billing-codes-section"><div class="billing-codes-label">CPT / E&M</div><div>';
|
||||
data.cpt.forEach(function(c) {
|
||||
var desc = c.desc ? ' <span class="billing-code-name">' + escHtml(c.desc) + '</span>' : '';
|
||||
html += '<span class="billing-code-chip cpt" title="Click to copy" data-code="' + escHtml(c.code) + '">' + escHtml(c.code) + desc + '</span>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
if (data.emLevel) {
|
||||
html += '<div class="billing-codes-section"><div class="billing-codes-label">E/M Assessment</div>';
|
||||
html += '<span class="billing-code-chip em">Level ' + data.emLevel.level + '</span>';
|
||||
html += '<span style="font-size:11px;color:var(--g500);margin-left:6px;">MDM: ' + data.emLevel.complexity + ' | ' + data.emLevel.diagnosisCount + ' dx | ' + data.emLevel.rosCount + ' ROS | ' + data.emLevel.peCount + ' PE</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<p style="font-size:10px;color:var(--g400);margin:8px 0 0;">Suggestions only. Always verify codes against your institution\'s coding guidelines.</p>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Wire click-to-copy on chips
|
||||
container.querySelectorAll('.billing-code-chip').forEach(function(chip) {
|
||||
chip.addEventListener('click', function() {
|
||||
var code = chip.dataset.code;
|
||||
if (code && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(code);
|
||||
showToast('Copied: ' + code, 'info');
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function() {
|
||||
container.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
|
||||
function refineDocument(outputElementId, inputElementId) {
|
||||
var doc = document.getElementById(outputElementId);
|
||||
var input = document.getElementById(inputElementId);
|
||||
if (!doc || !input) return;
|
||||
var docText = doc.innerText.trim();
|
||||
var instructions = input.value.trim();
|
||||
if (!docText) { showToast('No document', 'error'); return; }
|
||||
if (!instructions) { showToast('Enter instructions', 'error'); return; }
|
||||
showBusy('Refining...');
|
||||
var body = { currentDocument: docText, instructions: instructions, model: getSelectedModel() };
|
||||
// Include original source material so AI can reference the full input
|
||||
if (doc.dataset.sourceContext) body.sourceContext = doc.dataset.sourceContext;
|
||||
fetch('/api/refine', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) { setOutputText(doc, data.refined); input.value = ''; showToast('Refined!', 'success'); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
function shortenDocument(outputElementId) {
|
||||
var doc = document.getElementById(outputElementId);
|
||||
if (!doc) return;
|
||||
var text = doc.innerText.trim();
|
||||
if (!text) { showToast('No document', 'error'); return; }
|
||||
showBusy('Shortening...');
|
||||
fetch('/api/shorten', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ document: text, model: getSelectedModel() })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) { setOutputText(doc, data.shortened); showToast('Shortened!', 'success'); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
function exportToNextcloud(elementId, docType) {
|
||||
var el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
var text = el.innerText.trim();
|
||||
if (!text) { showToast('Nothing to export', 'error'); return; }
|
||||
fetch('/api/nextcloud/export', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ content: text, filename: docType + '-' + Date.now(), type: docType })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) showToast(data.message, 'success');
|
||||
else showToast(data.error || 'Export failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Nextcloud not connected', 'error'); });
|
||||
}
|
||||
|
||||
function createSpeechRecognition() {
|
||||
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
|
||||
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
var rec = new SR();
|
||||
rec.continuous = true;
|
||||
rec.interimResults = true;
|
||||
rec.lang = 'en-US';
|
||||
rec.maxAlternatives = 1;
|
||||
return rec;
|
||||
}
|
||||
|
||||
// Deduplicate speech recognition finals — Chrome can repeat text across restarts
|
||||
function deduplicateFinal(newText, existingText) {
|
||||
if (!newText || !existingText) return newText;
|
||||
var trimmed = newText.trim();
|
||||
if (!trimmed) return '';
|
||||
// Check if the new text is already at the end of existing text
|
||||
if (existingText.trimEnd().endsWith(trimmed)) return '';
|
||||
// Check for partial overlap (last sentence repeated)
|
||||
var words = trimmed.split(/\s+/);
|
||||
if (words.length >= 3) {
|
||||
var tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' ');
|
||||
if (tail === trimmed) return '';
|
||||
// Check if first half of new text overlaps with end of existing
|
||||
var half = Math.ceil(words.length / 2);
|
||||
var firstHalf = words.slice(0, half).join(' ');
|
||||
if (existingText.trimEnd().endsWith(firstHalf)) {
|
||||
return words.slice(half).join(' ') + ' ';
|
||||
}
|
||||
}
|
||||
return newText;
|
||||
}
|
||||
|
||||
// PWA Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function() {});
|
||||
}
|
||||
|
||||
console.log('✅ App.js loaded');
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
// ============================================================
|
||||
// AUDIO BACKUP — Server-side audio backup with local IndexedDB fallback
|
||||
// Saves audio to server (gzip compressed in PostgreSQL), auto-deletes 24h.
|
||||
// Falls back to IndexedDB if server save fails.
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
var DB_NAME = 'PedScribeAudioBackup';
|
||||
var STORE_NAME = 'recordings';
|
||||
var DB_VERSION = 1;
|
||||
var MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
var _db = null;
|
||||
|
||||
function openDB() {
|
||||
if (_db) return Promise.resolve(_db);
|
||||
return new Promise(function(resolve, reject) {
|
||||
var request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = function(e) {
|
||||
var db = e.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
var store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
};
|
||||
request.onsuccess = function(e) { _db = e.target.result; resolve(_db); };
|
||||
request.onerror = function() { reject(new Error('IndexedDB open failed')); };
|
||||
});
|
||||
}
|
||||
|
||||
// Save audio — tries server first, falls back to IndexedDB
|
||||
window.saveAudioBackup = function(blob, module) {
|
||||
// Try server save first
|
||||
return saveToServer(blob, module).then(function(serverId) {
|
||||
if (serverId) {
|
||||
window._lastAudioBackupId = 'server_' + serverId;
|
||||
return serverId;
|
||||
}
|
||||
// Fallback to IndexedDB
|
||||
return saveToIndexedDB(blob, module);
|
||||
}).catch(function() {
|
||||
return saveToIndexedDB(blob, module);
|
||||
});
|
||||
};
|
||||
|
||||
function saveToServer(blob, module) {
|
||||
var formData = new FormData();
|
||||
formData.append('audio', blob, 'audio.webm');
|
||||
formData.append('module', module || 'encounter');
|
||||
|
||||
var headers = {};
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
return fetch('/api/audio-backups', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
credentials: 'same-origin',
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) return data.id;
|
||||
return null;
|
||||
})
|
||||
.catch(function() { return null; });
|
||||
}
|
||||
|
||||
function saveToIndexedDB(blob, module) {
|
||||
return openDB().then(function(db) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
var store = tx.objectStore(STORE_NAME);
|
||||
var record = {
|
||||
blob: blob,
|
||||
module: module || 'unknown',
|
||||
timestamp: Date.now(),
|
||||
size: blob.size,
|
||||
mimeType: blob.type
|
||||
};
|
||||
var req = store.add(record);
|
||||
req.onsuccess = function() { resolve(req.result); };
|
||||
req.onerror = function() { reject(new Error('Failed to save audio backup')); };
|
||||
});
|
||||
}).then(function(id) {
|
||||
window._lastAudioBackupId = 'local_' + id;
|
||||
cleanupOldLocalBackups();
|
||||
return id;
|
||||
}).catch(function(err) {
|
||||
console.warn('[AudioBackup] Save failed:', err.message);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// Delete a specific backup (server or local)
|
||||
window.deleteAudioBackup = function(id) {
|
||||
if (typeof id === 'string' && id.startsWith('server_')) {
|
||||
var serverId = id.replace('server_', '');
|
||||
return fetch('/api/audio-backups/' + serverId, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
}).then(function() {}).catch(function() {});
|
||||
}
|
||||
// Local IndexedDB delete
|
||||
var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id;
|
||||
return openDB().then(function(db) {
|
||||
return new Promise(function(resolve) {
|
||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).delete(localId);
|
||||
tx.oncomplete = function() { resolve(); };
|
||||
tx.onerror = function() { resolve(); };
|
||||
});
|
||||
}).catch(function() {});
|
||||
};
|
||||
|
||||
// Get all backups (merged: server + local)
|
||||
window.getAudioBackups = function() {
|
||||
var serverPromise = fetchServerBackups();
|
||||
var localPromise = getLocalBackups();
|
||||
|
||||
return Promise.all([serverPromise, localPromise]).then(function(results) {
|
||||
var server = results[0].map(function(b) {
|
||||
return {
|
||||
id: 'server_' + b.id,
|
||||
module: b.module,
|
||||
timestamp: new Date(b.created_at).getTime(),
|
||||
size: b.size_bytes,
|
||||
compressedSize: b.compressed_bytes,
|
||||
source: 'server',
|
||||
expiresAt: b.expires_at
|
||||
};
|
||||
});
|
||||
var local = results[1].map(function(b) {
|
||||
return {
|
||||
id: 'local_' + b.id,
|
||||
module: b.module,
|
||||
timestamp: b.timestamp,
|
||||
size: b.size,
|
||||
source: 'local'
|
||||
};
|
||||
});
|
||||
return server.concat(local).sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||
});
|
||||
};
|
||||
|
||||
function fetchServerBackups() {
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
return fetch('/api/audio-backups', {
|
||||
headers: headers,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) { return data.success ? (data.backups || []) : []; })
|
||||
.catch(function() { return []; });
|
||||
}
|
||||
|
||||
function getLocalBackups() {
|
||||
return openDB().then(function(db) {
|
||||
return new Promise(function(resolve) {
|
||||
var tx = db.transaction(STORE_NAME, 'readonly');
|
||||
var req = tx.objectStore(STORE_NAME).getAll();
|
||||
req.onsuccess = function() {
|
||||
var records = (req.result || []).filter(function(r) {
|
||||
return (Date.now() - r.timestamp) < MAX_AGE_MS;
|
||||
});
|
||||
resolve(records);
|
||||
};
|
||||
req.onerror = function() { resolve([]); };
|
||||
});
|
||||
}).catch(function() { return []; });
|
||||
}
|
||||
|
||||
// Retry transcription from backup
|
||||
window.retryAudioBackup = function(id) {
|
||||
if (typeof id === 'string' && id.startsWith('server_')) {
|
||||
var serverId = id.replace('server_', '');
|
||||
showLoading('Downloading and re-transcribing...');
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
return fetch('/api/audio-backups/' + serverId + '/audio', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); })
|
||||
.then(function(blob) {
|
||||
window._lastAudioBackupId = id;
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
showToast('Backup transcribed!', 'success');
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(data.text);
|
||||
showToast('Transcript copied to clipboard', 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('Retry failed: ' + (data.error || 'unknown'), 'error');
|
||||
}
|
||||
return data;
|
||||
});
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast('Retry failed: ' + err.message, 'error'); });
|
||||
}
|
||||
|
||||
// Local IndexedDB retry
|
||||
var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id;
|
||||
return openDB().then(function(db) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var tx = db.transaction(STORE_NAME, 'readonly');
|
||||
var req = tx.objectStore(STORE_NAME).get(localId);
|
||||
req.onsuccess = function() {
|
||||
if (!req.result) { reject(new Error('Backup not found')); return; }
|
||||
resolve(req.result);
|
||||
};
|
||||
req.onerror = function() { reject(new Error('Failed to read backup')); };
|
||||
});
|
||||
}).then(function(record) {
|
||||
showLoading('Re-transcribing audio backup...');
|
||||
window._lastAudioBackupId = id;
|
||||
return transcribeAudio(record.blob).then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
showToast('Backup transcribed!', 'success');
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(data.text);
|
||||
showToast('Transcript copied to clipboard', 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('Retry failed: ' + (data.error || 'unknown'), 'error');
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function cleanupOldLocalBackups() {
|
||||
openDB().then(function(db) {
|
||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
var store = tx.objectStore(STORE_NAME);
|
||||
var index = store.index('timestamp');
|
||||
var cutoff = Date.now() - MAX_AGE_MS;
|
||||
var range = IDBKeyRange.upperBound(cutoff);
|
||||
var req = index.openCursor(range);
|
||||
req.onsuccess = function(e) {
|
||||
var cursor = e.target.result;
|
||||
if (cursor) { cursor.delete(); cursor.continue(); }
|
||||
};
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
// Render audio backups list in settings
|
||||
window.renderAudioBackups = function() {
|
||||
var container = document.getElementById('audio-backups-list');
|
||||
if (!container) return;
|
||||
getAudioBackups().then(function(backups) {
|
||||
if (backups.length === 0) {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No audio backups. Recordings are saved automatically to the server and kept for 24 hours.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = backups.map(function(b) {
|
||||
var date = new Date(b.timestamp);
|
||||
var sizeKb = Math.round(b.size / 1024);
|
||||
var age = Math.round((Date.now() - b.timestamp) / 60000);
|
||||
var ageStr = age < 60 ? age + 'm ago' : Math.round(age / 60) + 'h ago';
|
||||
var sourceTag = b.source === 'server'
|
||||
? '<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--blue-light);color:var(--blue);">server</span>'
|
||||
: '<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g500);">local</span>';
|
||||
var compInfo = b.compressedSize ? ' (' + Math.round(b.compressedSize / 1024) + ' KB compressed)' : '';
|
||||
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
|
||||
'<div style="flex:1;">' +
|
||||
'<div style="font-weight:600;font-size:13px;display:flex;align-items:center;gap:6px;">' + esc(b.module) + ' recording ' + sourceTag + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--g500);">' + date.toLocaleString() + ' · ' + sizeKb + ' KB' + compInfo + ' · ' + ageStr + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-primary audio-backup-retry" data-id="' + b.id + '"><i class="fas fa-rotate-right"></i> Retry</button>' +
|
||||
'<button class="btn-sm btn-ghost audio-backup-delete" data-id="' + b.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
container.querySelectorAll('.audio-backup-retry').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id); });
|
||||
});
|
||||
container.querySelectorAll('.audio-backup-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
deleteAudioBackup(btn.dataset.id).then(function() {
|
||||
showToast('Backup deleted', 'info');
|
||||
renderAudioBackups();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
cleanupOldLocalBackups();
|
||||
})();
|
||||
|
|
@ -1,877 +0,0 @@
|
|||
// ============================================================
|
||||
// AUTH.JS — Login, Register, 2FA, Session Management
|
||||
// ============================================================
|
||||
|
||||
// Wait for DOM to be fully ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
var authScreen = document.getElementById('auth-screen');
|
||||
var mainApp = document.getElementById('main-app');
|
||||
var loginForm = document.getElementById('login-form');
|
||||
var registerForm = document.getElementById('register-form');
|
||||
var forgotForm = document.getElementById('forgot-form');
|
||||
var userName = document.getElementById('user-name');
|
||||
|
||||
var TOKEN_KEY = 'ped_scribe_token';
|
||||
var USER_KEY = 'ped_scribe_user';
|
||||
var SESSION_KEY = 'ped_session_id';
|
||||
|
||||
// Runtime-split auth model:
|
||||
// - Web browser → httpOnly cookie only (no localStorage token, XSS-safe).
|
||||
// fetch() defaults to credentials:'same-origin' so cookies are sent.
|
||||
// - Capacitor native app → Bearer token in iOS Keychain / Android Keystore
|
||||
// via SecureStorage wrapper. WebView cookies can be evicted; Keychain
|
||||
// survives cold starts reliably.
|
||||
function isNativeApp() {
|
||||
try {
|
||||
return !!(window.Capacitor && typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform());
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
window.IS_NATIVE_APP = isNativeApp();
|
||||
|
||||
// Hide elements that only make sense for the web audience (e.g. the
|
||||
// "Download Android APK" link on the login page — you're already in
|
||||
// the app). Add more element IDs here as other web-only UI appears.
|
||||
if (isNativeApp()) {
|
||||
['apk-download-link'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Auth module initialized
|
||||
|
||||
// Make sure forms are visible/hidden correctly on load
|
||||
showLoginForm();
|
||||
|
||||
// Auth screen is hidden by CSS default — only show it when there is no valid session
|
||||
function showAuthScreen() {
|
||||
if (authScreen) authScreen.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Check for SSO redirect (token is in httpOnly cookie) ──
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var ssoOk = urlParams.get('sso');
|
||||
var ssoError = urlParams.get('error');
|
||||
if (ssoOk === 'ok') {
|
||||
var ssoSid = urlParams.get('sid');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
if (ssoSid && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, ssoSid);
|
||||
}
|
||||
// Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically)
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); })
|
||||
.then(function(data) {
|
||||
if (data && data.user) {
|
||||
enterApp(data.user, '');
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
} else { showAuthScreen(); clearSession(); }
|
||||
})
|
||||
.catch(function() { showAuthScreen(); clearSession(); });
|
||||
} else if (ssoError) {
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
var errorMsgs = {
|
||||
invalid_state: 'SSO session expired',
|
||||
expired: 'SSO session expired',
|
||||
no_email: 'Your identity provider did not return an email',
|
||||
disabled: 'Account disabled',
|
||||
sso_failed: 'SSO login failed',
|
||||
email_unverified: 'Your identity provider did not confirm your email is verified. Contact your administrator.',
|
||||
sub_mismatch: 'Your SSO identity does not match the linked account. Contact your administrator.'
|
||||
};
|
||||
showAuthScreen();
|
||||
setTimeout(function() { showToast(errorMsgs[ssoError] || 'SSO error', 'error'); }, 300);
|
||||
}
|
||||
|
||||
// ── Check OIDC status to show/hide SSO button ──
|
||||
fetch('/api/auth/oidc-status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.oidcEnabled) {
|
||||
var ssoBtn = document.getElementById('btn-sso');
|
||||
var ssoDivider = document.getElementById('sso-divider');
|
||||
var ssoLabel = document.getElementById('sso-label');
|
||||
if (ssoBtn) ssoBtn.style.display = 'block';
|
||||
if (ssoDivider) ssoDivider.style.display = 'block';
|
||||
if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel;
|
||||
if (data.disableLocalAuth) {
|
||||
// Hide local login form fields, only show SSO
|
||||
var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, #show-register, #show-forgot');
|
||||
localFields.forEach(function(el) { el.style.display = 'none'; });
|
||||
if (ssoDivider) ssoDivider.style.display = 'none';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
|
||||
if (isNativeApp()) {
|
||||
// Native app path: token lives in Keychain/Keystore via SecureStorage
|
||||
window.SecureStorage.hydrate([TOKEN_KEY, USER_KEY, SESSION_KEY]).then(function() {
|
||||
var savedToken = window.SecureStorage.getSync(TOKEN_KEY);
|
||||
if (ssoOk || ssoError) return; // SSO branch handled above
|
||||
if (!savedToken) { showAuthScreen(); return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + savedToken } })
|
||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('expired'); })
|
||||
.then(function(data) {
|
||||
if (data && data.user) enterApp(data.user, savedToken);
|
||||
else { showAuthScreen(); clearSession(); }
|
||||
})
|
||||
.catch(function() { showAuthScreen(); clearSession(); });
|
||||
});
|
||||
} else {
|
||||
// Web path: rely on httpOnly cookie. fetch sends same-origin cookies
|
||||
// automatically in modern browsers — no Authorization header needed.
|
||||
if (ssoOk || ssoError) {
|
||||
// SSO branch handled above
|
||||
} else {
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('not-logged-in'); })
|
||||
.then(function(data) {
|
||||
if (data && data.user) enterApp(data.user, '');
|
||||
else showAuthScreen();
|
||||
})
|
||||
.catch(function() { showAuthScreen(); });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HELPER FUNCTIONS ----
|
||||
|
||||
function showLoginForm() {
|
||||
if (loginForm) loginForm.style.display = 'block';
|
||||
if (registerForm) registerForm.style.display = 'none';
|
||||
if (forgotForm) forgotForm.style.display = 'none';
|
||||
}
|
||||
|
||||
function showRegisterForm() {
|
||||
if (loginForm) loginForm.style.display = 'none';
|
||||
if (registerForm) registerForm.style.display = 'block';
|
||||
if (forgotForm) forgotForm.style.display = 'none';
|
||||
}
|
||||
|
||||
function showForgotForm() {
|
||||
if (loginForm) loginForm.style.display = 'none';
|
||||
if (registerForm) registerForm.style.display = 'none';
|
||||
if (forgotForm) forgotForm.style.display = 'block';
|
||||
}
|
||||
|
||||
function enterApp(user, token) {
|
||||
window.AUTH_TOKEN = token;
|
||||
window.CURRENT_USER = user; // cached so settings page doesn't re-fetch /me just to read canLocalAuth
|
||||
if (isNativeApp()) {
|
||||
// Mobile: persist token to Keychain/Keystore
|
||||
window.SecureStorage.set(TOKEN_KEY, token);
|
||||
window.SecureStorage.set(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
// Web: do not persist the token anywhere — session lives in the
|
||||
// httpOnly cookie already set by the server. User info is
|
||||
// re-fetched via /api/auth/me on each boot.
|
||||
|
||||
if (authScreen) authScreen.style.display = 'none';
|
||||
if (mainApp) {
|
||||
mainApp.style.display = 'block';
|
||||
mainApp.className = mainApp.className.replace('hidden', '').trim();
|
||||
}
|
||||
if (userName) userName.textContent = user.name || user.email;
|
||||
|
||||
// Show admin tab for admins only
|
||||
var adminTabBtn = document.getElementById('admin-tab-btn');
|
||||
if (adminTabBtn) {
|
||||
if (user.role === 'admin') adminTabBtn.classList.remove('hidden');
|
||||
else adminTabBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Show Content Manager tab for admin + moderator
|
||||
var cmsTabBtn = document.getElementById('cms-tab-btn');
|
||||
if (cmsTabBtn) {
|
||||
if (user.role === 'admin' || user.role === 'moderator') cmsTabBtn.classList.remove('hidden');
|
||||
else cmsTabBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Store role globally for JS modules to check
|
||||
window._userRole = user.role;
|
||||
|
||||
console.log('[Auth] ✅ Entered app as:', user.email, 'Role:', user.role);
|
||||
|
||||
// Restore last visited tab (saved by activateTab)
|
||||
var lastTab = null;
|
||||
try { lastTab = localStorage.getItem('ped_last_tab'); } catch(e) {}
|
||||
if (typeof window.activateTab === 'function') {
|
||||
if (!lastTab || !window.activateTab(lastTab)) {
|
||||
window.activateTab('encounter');
|
||||
}
|
||||
}
|
||||
|
||||
// Load announcement banner if function is available
|
||||
if (typeof loadAnnouncement === 'function') loadAnnouncement();
|
||||
|
||||
// Check if server-side transcription is configured
|
||||
if (typeof checkTranscribeStatus === 'function') checkTranscribeStatus();
|
||||
}
|
||||
|
||||
function exitApp() {
|
||||
// Destroy session server-side before clearing local state
|
||||
fetch('/api/auth/logout', { method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin' }).catch(function() {});
|
||||
// Tell sibling tabs to drop their UI too.
|
||||
try { if (window.__authBroadcast) window.__authBroadcast.postMessage({ type: 'logout' }); } catch(e) {}
|
||||
clearSession();
|
||||
document.documentElement.classList.remove('has-session');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
if (authScreen) authScreen.style.display = 'flex';
|
||||
if (mainApp) mainApp.style.display = 'none';
|
||||
var adminTabBtn = document.getElementById('admin-tab-btn');
|
||||
if (adminTabBtn) adminTabBtn.classList.add('hidden');
|
||||
showLoginForm();
|
||||
// Clear fields after browser autofill has had a chance to run
|
||||
setTimeout(function() {
|
||||
['login-email', 'login-password', 'reg-name', 'reg-email', 'reg-password', 'login-totp'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
if (isNativeApp()) {
|
||||
window.SecureStorage.remove(TOKEN_KEY);
|
||||
window.SecureStorage.remove(USER_KEY);
|
||||
window.SecureStorage.remove(SESSION_KEY);
|
||||
} else {
|
||||
// Web: cookie cleared by /api/auth/logout. Also wipe any legacy
|
||||
// localStorage entries from the dual-mode era so they don't linger.
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch(e) {}
|
||||
}
|
||||
window.AUTH_TOKEN = null;
|
||||
// Clear service worker caches so a logged-out user on a shared device
|
||||
// cannot read cached pages from offline mode.
|
||||
try {
|
||||
if (window.caches && caches.keys) {
|
||||
caches.keys().then(function(names) {
|
||||
names.forEach(function(n) { caches.delete(n); });
|
||||
}).catch(function(){});
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
window.getAuthHeaders = function() {
|
||||
// Web: no Authorization header. The httpOnly cookie is sent automatically
|
||||
// by fetch (default credentials = same-origin). Server middleware falls
|
||||
// back to cookie when Bearer is absent.
|
||||
if (!isNativeApp()) {
|
||||
return { 'Content-Type': 'application/json' };
|
||||
}
|
||||
// Native: Bearer from Keychain/Keystore via SecureStorage.
|
||||
var token = window.AUTH_TOKEN || window.SecureStorage.getSync(TOKEN_KEY) || '';
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
};
|
||||
};
|
||||
|
||||
// ---- FORM TOGGLE LINKS ----
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var target = e.target;
|
||||
|
||||
// Handle link clicks by ID
|
||||
if (target.id === 'show-register' || target.closest('#show-register')) {
|
||||
e.preventDefault();
|
||||
showRegisterForm();
|
||||
}
|
||||
if (target.id === 'show-login' || target.closest('#show-login')) {
|
||||
e.preventDefault();
|
||||
showLoginForm();
|
||||
}
|
||||
if (target.id === 'show-login-2' || target.closest('#show-login-2')) {
|
||||
e.preventDefault();
|
||||
showLoginForm();
|
||||
}
|
||||
if (target.id === 'show-forgot' || target.closest('#show-forgot')) {
|
||||
e.preventDefault();
|
||||
showForgotForm();
|
||||
}
|
||||
|
||||
// Logout button
|
||||
if (target.id === 'btn-logout' || target.closest('#btn-logout')) {
|
||||
e.preventDefault();
|
||||
exitApp();
|
||||
showToast('Logged out', 'info');
|
||||
}
|
||||
|
||||
// About modal
|
||||
if (target.id === 'btn-show-about' || target.closest('#btn-show-about')) {
|
||||
e.preventDefault();
|
||||
var aboutModal = document.getElementById('about-modal');
|
||||
if (aboutModal) aboutModal.classList.remove('hidden');
|
||||
}
|
||||
if (target.id === 'btn-close-about' || target.closest('#btn-close-about')) {
|
||||
e.preventDefault();
|
||||
var aboutModal = document.getElementById('about-modal');
|
||||
if (aboutModal) aboutModal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Settings button — navigate to settings tab
|
||||
if (target.id === 'btn-settings' || target.closest('#btn-settings')) {
|
||||
e.preventDefault();
|
||||
var settingsBtn = document.querySelector('.tab-btn[data-tab="settings"]');
|
||||
if (settingsBtn) settingsBtn.click();
|
||||
load2FAStatus();
|
||||
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
|
||||
if (typeof loadMemories === 'function') loadMemories();
|
||||
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
|
||||
}
|
||||
|
||||
// Setup 2FA
|
||||
if (target.id === 'btn-setup-2fa' || target.closest('#btn-setup-2fa')) {
|
||||
e.preventDefault();
|
||||
fetch('/api/auth/setup-2fa', { method: 'POST', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
var qr = document.getElementById('2fa-qr');
|
||||
var secret = document.getElementById('2fa-secret');
|
||||
var setup = document.getElementById('2fa-setup');
|
||||
if (qr) qr.src = data.qrCode;
|
||||
if (secret) secret.textContent = data.secret;
|
||||
if (setup) { setup.style.display = 'block'; setup.className = setup.className.replace('hidden', '').trim(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Verify 2FA
|
||||
if (target.id === 'btn-verify-2fa' || target.closest('#btn-verify-2fa')) {
|
||||
e.preventDefault();
|
||||
var code = document.getElementById('2fa-verify-code').value;
|
||||
fetch('/api/auth/verify-2fa', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ code: code })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast('2FA enabled!', 'success');
|
||||
var setup = document.getElementById('2fa-setup');
|
||||
if (setup) setup.style.display = 'none';
|
||||
if (data.backupCodes && data.backupCodes.length) {
|
||||
showBackupCodesModal(data.backupCodes);
|
||||
}
|
||||
load2FAStatus();
|
||||
} else {
|
||||
showToast(data.error || 'Invalid code', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Regenerate backup codes
|
||||
if (target.id === 'btn-regen-backup-codes' || target.closest('#btn-regen-backup-codes')) {
|
||||
e.preventDefault();
|
||||
showConfirm('Regenerate backup codes? This invalidates your existing ones. Enter your current password to confirm.', function(pw) {
|
||||
if (!pw) return;
|
||||
fetch('/api/auth/2fa/backup-codes', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ password: pw })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success && data.codes) showBackupCodesModal(data.codes);
|
||||
else showToast(data.error || 'Failed to regenerate codes', 'error');
|
||||
});
|
||||
}, {
|
||||
input: true,
|
||||
inputType: 'password',
|
||||
placeholder: 'Current password',
|
||||
confirmText: 'Regenerate',
|
||||
required: true,
|
||||
requiredMsg: 'Password is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Disable 2FA
|
||||
if (target.id === 'btn-disable-2fa' || target.closest('#btn-disable-2fa')) {
|
||||
e.preventDefault();
|
||||
var confirmBox = document.getElementById('2fa-disable-confirm');
|
||||
if (confirmBox) { confirmBox.classList.remove('hidden'); }
|
||||
return;
|
||||
}
|
||||
if (target.id === 'btn-disable-2fa-cancel' || target.closest('#btn-disable-2fa-cancel')) {
|
||||
e.preventDefault();
|
||||
var confirmBox = document.getElementById('2fa-disable-confirm');
|
||||
if (confirmBox) { confirmBox.classList.add('hidden'); }
|
||||
var pwField = document.getElementById('2fa-disable-password');
|
||||
if (pwField) pwField.value = '';
|
||||
return;
|
||||
}
|
||||
if (target.id === 'btn-disable-2fa-confirm' || target.closest('#btn-disable-2fa-confirm')) {
|
||||
e.preventDefault();
|
||||
var pwField = document.getElementById('2fa-disable-password');
|
||||
var pw = pwField ? pwField.value : '';
|
||||
if (!pw) { showToast('Enter your password', 'error'); return; }
|
||||
fetch('/api/auth/disable-2fa', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ password: pw })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var confirmBox = document.getElementById('2fa-disable-confirm');
|
||||
if (confirmBox) confirmBox.classList.add('hidden');
|
||||
if (pwField) pwField.value = '';
|
||||
if (data.success) { showToast('2FA disabled', 'info'); load2FAStatus(); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---- LOGIN FORM SUBMIT ----
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var email = document.getElementById('login-email').value.trim();
|
||||
var password = document.getElementById('login-password').value;
|
||||
var totpEl = document.getElementById('login-totp');
|
||||
var totpCode = totpEl ? totpEl.value.trim() : '';
|
||||
|
||||
if (!email || !password) {
|
||||
showToast('Enter email and password', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cloudflare Turnstile
|
||||
var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]');
|
||||
var loginToken = loginTurnstile ? loginTurnstile.value : '';
|
||||
if (!loginToken) {
|
||||
showToast('Please complete the verification', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
showLoading('Signing in...');
|
||||
|
||||
var body = { email: email, password: password, turnstileToken: loginToken };
|
||||
if (totpCode) body.totpCode = totpCode;
|
||||
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
|
||||
if (data.requires2FA) {
|
||||
var grp = document.getElementById('totp-group');
|
||||
if (grp) { grp.style.display = 'block'; grp.className = grp.className.replace('hidden', '').trim(); }
|
||||
showToast('Enter your 2FA code', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.needsVerification) {
|
||||
var resendBox = document.getElementById('resend-verify-box');
|
||||
if (resendBox) resendBox.className = resendBox.className.replace('hidden', '').trim();
|
||||
showToast('Verify your email first. Check inbox.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
if (data.sessionId && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Login failed', 'error');
|
||||
if (window.turnstile) turnstile.reset('#turnstile-login');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
console.error('[Auth] Login error:', err);
|
||||
showToast('Connection error', 'error');
|
||||
if (window.turnstile) turnstile.reset('#turnstile-login');
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- RESEND VERIFICATION LINK ----
|
||||
var resendLink = document.getElementById('resend-verify-link');
|
||||
if (resendLink) {
|
||||
resendLink.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('login-email').value.trim();
|
||||
if (!email) { showToast('Enter your email first', 'error'); return; }
|
||||
resendLink.style.pointerEvents = 'none';
|
||||
resendLink.textContent = 'Sending...';
|
||||
fetch('/api/auth/resend-verification', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
resendLink.style.pointerEvents = '';
|
||||
resendLink.textContent = 'Resend verification link';
|
||||
if (data.error) {
|
||||
showToast(data.error, 'error');
|
||||
} else {
|
||||
showToast('Verification email sent! Check your inbox.', 'success');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
resendLink.style.pointerEvents = '';
|
||||
resendLink.textContent = 'Resend verification link';
|
||||
showToast('Connection error', 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- REGISTER FORM SUBMIT ----
|
||||
if (registerForm) {
|
||||
registerForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var name = document.getElementById('reg-name').value.trim();
|
||||
var email = document.getElementById('reg-email').value.trim();
|
||||
var password = document.getElementById('reg-password').value;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
showToast('Fill all fields', 'error');
|
||||
return false;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
showToast('Password must be 8+ characters', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cloudflare Turnstile verification
|
||||
var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]');
|
||||
var turnstileToken = turnstileResponse ? turnstileResponse.value : '';
|
||||
if (!turnstileToken) {
|
||||
showToast('Please complete the verification challenge', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
showLoading('Creating account...');
|
||||
|
||||
fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
if (data.sessionId && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast(data.message || 'Account created!', 'success');
|
||||
} else if (data.success && data.needsVerification) {
|
||||
showToast(data.message || 'Check email to verify', 'success');
|
||||
showLoginForm();
|
||||
} else {
|
||||
showToast(data.error || 'Registration failed', 'error');
|
||||
if (window.turnstile) turnstile.reset();
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
console.error('[Auth] Register error:', err);
|
||||
showToast('Connection error', 'error');
|
||||
if (window.turnstile) turnstile.reset();
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- FORGOT PASSWORD SUBMIT ----
|
||||
if (forgotForm) {
|
||||
forgotForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var email = document.getElementById('forgot-email').value.trim();
|
||||
if (!email) { showToast('Enter email', 'error'); return false; }
|
||||
|
||||
// Cloudflare Turnstile
|
||||
var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]');
|
||||
var forgotToken = forgotTurnstile ? forgotTurnstile.value : '';
|
||||
if (!forgotToken) {
|
||||
showToast('Please complete the verification', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
showLoading('Sending...');
|
||||
|
||||
fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email, turnstileToken: forgotToken })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
showToast(data.message || 'Check your email', 'success');
|
||||
showLoginForm();
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
showToast('Error', 'error');
|
||||
if (window.turnstile) turnstile.reset('#turnstile-forgot');
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SESSION MANAGEMENT ----
|
||||
function timeAgo(date) {
|
||||
var s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||
return Math.floor(s / 86400) + 'd ago';
|
||||
}
|
||||
function escH(str) { var d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; }
|
||||
|
||||
window.loadSessions = function() {
|
||||
var list = document.getElementById('sessions-list');
|
||||
if (!list) return;
|
||||
fetch('/api/sessions', { headers: getAuthHeaders(), credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success || !data.sessions || data.sessions.length === 0) {
|
||||
list.innerHTML = '<p style="color:var(--g400);font-size:13px;">No active sessions found.</p>';
|
||||
return;
|
||||
}
|
||||
var currentSid = data.currentSessionId || (window.SecureStorage ? window.SecureStorage.getSync(SESSION_KEY) : localStorage.getItem(SESSION_KEY));
|
||||
list.innerHTML = data.sessions.map(function(s) {
|
||||
var isCurrent = s.id === currentSid;
|
||||
var created = new Date(s.created_at).toLocaleDateString();
|
||||
var lastAct = timeAgo(new Date(s.last_activity));
|
||||
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--g50);border-radius:8px;border:1.5px solid ' + (isCurrent ? 'var(--blue)' : 'var(--g200)') + ';">'
|
||||
+ '<div>'
|
||||
+ '<div style="font-size:13px;font-weight:600;color:var(--g800);">' + escH(s.device_label || 'Unknown device') + (isCurrent ? ' <span style="font-size:11px;color:var(--blue);font-weight:500;">(this device)</span>' : '') + '</div>'
|
||||
+ '<div style="font-size:12px;color:var(--g500);margin-top:2px;">' + escH(s.ip_address || '') + ' · Created ' + created + ' · Active ' + lastAct + '</div>'
|
||||
+ '</div>'
|
||||
+ (isCurrent ? '' : '<button class="btn-sm btn-ghost session-revoke-btn" data-sid="' + s.id + '" style="color:var(--red);font-size:12px;"><i class="fas fa-xmark"></i> Revoke</button>')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
// Wire revoke buttons
|
||||
list.querySelectorAll('.session-revoke-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
showConfirm('Revoke this session? That device will be logged out.', function() {
|
||||
fetch('/api/sessions/' + btn.dataset.sid, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) { showToast('Session revoked', 'info'); loadSessions(); }
|
||||
else showToast(d.error || 'Failed', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function() {
|
||||
list.innerHTML = '<p style="color:var(--g400);font-size:13px;">Could not load sessions.</p>';
|
||||
});
|
||||
};
|
||||
|
||||
// Revoke all other sessions button
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-revoke-all-sessions' || e.target.closest('#btn-revoke-all-sessions')) {
|
||||
e.preventDefault();
|
||||
showConfirm('Revoke all other sessions? All other devices will be logged out.', function() {
|
||||
fetch('/api/sessions', { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) { showToast('All other sessions revoked (' + (d.revoked || 0) + ' removed)', 'info'); loadSessions(); }
|
||||
});
|
||||
}, { danger: true, confirmText: 'Revoke All' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- CHANGE PASSWORD ----
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-change-password' || e.target.closest('#btn-change-password')) {
|
||||
e.preventDefault();
|
||||
var current = document.getElementById('pw-current');
|
||||
var newPw = document.getElementById('pw-new');
|
||||
var confirmPw = document.getElementById('pw-confirm');
|
||||
var status = document.getElementById('pw-change-status');
|
||||
if (!current || !newPw || !confirmPw) return;
|
||||
|
||||
if (!current.value || !newPw.value) { showToast('Fill in all fields', 'error'); return; }
|
||||
if (newPw.value.length < 8) { showToast('New password must be 8+ characters', 'error'); return; }
|
||||
if (newPw.value !== confirmPw.value) { showToast('Passwords do not match', 'error'); return; }
|
||||
|
||||
if (status) { status.textContent = 'Changing...'; status.style.color = 'var(--g500)'; }
|
||||
|
||||
fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ currentPassword: current.value, newPassword: newPw.value })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast(data.message || 'Password changed', 'success');
|
||||
if (data.passwordWarning) setTimeout(function() { showToast(data.passwordWarning, 'warning'); }, 1500);
|
||||
current.value = ''; newPw.value = ''; confirmPw.value = '';
|
||||
if (status) { status.textContent = ''; }
|
||||
if (typeof loadSessions === 'function') loadSessions();
|
||||
} else {
|
||||
showToast(data.error || 'Failed', 'error');
|
||||
if (status) { status.textContent = ''; }
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Connection error', 'error'); if (status) status.textContent = ''; });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 2FA STATUS ----
|
||||
function load2FAStatus() {
|
||||
fetch('/api/auth/me', { credentials: 'same-origin', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data || !data.user) {
|
||||
// /me failed — if we already have a cached user from login, fall back to that
|
||||
// so reloaded Settings pages still render for local-auth users.
|
||||
if (window.CURRENT_USER && window.CURRENT_USER.canLocalAuth === true) {
|
||||
data = { user: window.CURRENT_USER };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Keep cache in sync
|
||||
window.CURRENT_USER = data.user;
|
||||
// Sections are display:none by default. Show them unless the server
|
||||
// explicitly marks this user as SSO-only (canLocalAuth === false).
|
||||
// Missing/undefined flag defaults to showing — safer than hiding a
|
||||
// legit local user's sections due to a transient fetch hiccup.
|
||||
var pwSection = document.getElementById('change-password-section');
|
||||
var twofaSection = document.getElementById('2fa-section');
|
||||
var sessionsSection = document.getElementById('sessions-section');
|
||||
if (data.user.canLocalAuth === false) {
|
||||
return; // SSO-only — keep sections hidden
|
||||
}
|
||||
if (pwSection) pwSection.style.display = '';
|
||||
if (twofaSection) twofaSection.style.display = '';
|
||||
if (sessionsSection) sessionsSection.style.display = '';
|
||||
var status = document.getElementById('2fa-status');
|
||||
var setupBtn = document.getElementById('btn-setup-2fa');
|
||||
var disableBtn = document.getElementById('btn-disable-2fa');
|
||||
|
||||
if (data.user.totp_enabled) {
|
||||
if (status) { status.textContent = 'Status: ✅ Enabled'; status.style.color = '#10b981'; }
|
||||
if (setupBtn) setupBtn.style.display = 'none';
|
||||
if (disableBtn) disableBtn.style.display = 'inline-flex';
|
||||
// Show backup-code row and remaining count
|
||||
fetch('/api/auth/2fa/backup-codes/count', { credentials: 'same-origin', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(bc) {
|
||||
var el = document.getElementById('2fa-backup-info');
|
||||
if (el && typeof bc.remaining === 'number') {
|
||||
var color = bc.remaining <= 2 ? '#f97316' : 'var(--g500)';
|
||||
el.innerHTML = '<div style="margin-top:12px;font-size:13px;color:' + color + ';">'
|
||||
+ '<span>' + bc.remaining + ' backup codes remaining.</span> '
|
||||
+ '<button id="btn-regen-backup-codes" class="btn-sm btn-ghost" style="margin-left:6px;">Regenerate</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
}).catch(function(){});
|
||||
} else {
|
||||
if (status) { status.textContent = 'Status: ❌ Not enabled'; status.style.color = '#ef4444'; }
|
||||
if (setupBtn) setupBtn.style.display = 'inline-flex';
|
||||
if (disableBtn) disableBtn.style.display = 'none';
|
||||
var bi = document.getElementById('2fa-backup-info');
|
||||
if (bi) bi.innerHTML = '';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
var status = document.getElementById('2fa-status');
|
||||
if (status) status.textContent = 'Status: Unable to load';
|
||||
});
|
||||
}
|
||||
// Expose globally so app.js tabChanged handler can call it
|
||||
window.load2FAStatus = load2FAStatus;
|
||||
|
||||
// Modal that shows backup codes exactly once. Uses textContent and a copy
|
||||
// button — codes never land in innerHTML so a stray value can't produce XSS.
|
||||
function showBackupCodesModal(codes) {
|
||||
var existing = document.getElementById('backup-codes-modal');
|
||||
if (existing) existing.remove();
|
||||
var modal = document.createElement('div');
|
||||
modal.id = 'backup-codes-modal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:9999;padding:20px;';
|
||||
|
||||
var box = document.createElement('div');
|
||||
box.style.cssText = 'background:#fff;border-radius:12px;max-width:460px;width:100%;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.3);';
|
||||
|
||||
var title = document.createElement('div');
|
||||
title.style.cssText = 'font-size:18px;font-weight:700;margin-bottom:4px;';
|
||||
title.textContent = 'Save your backup codes';
|
||||
var hint = document.createElement('div');
|
||||
hint.style.cssText = 'font-size:13px;color:#6b7280;margin-bottom:16px;line-height:1.5;';
|
||||
hint.textContent = 'Each code works once. Store them somewhere safe — you will NOT see them again. Use one in place of your 2FA code if you lose your authenticator.';
|
||||
|
||||
var list = document.createElement('pre');
|
||||
list.style.cssText = 'background:#f3f4f6;padding:12px 14px;border-radius:8px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px;line-height:1.8;margin:0;white-space:pre-wrap;user-select:all;';
|
||||
list.textContent = codes.join('\n');
|
||||
|
||||
var btnRow = document.createElement('div');
|
||||
btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px;justify-content:flex-end;';
|
||||
|
||||
var copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'btn-sm btn-ghost';
|
||||
copyBtn.textContent = 'Copy';
|
||||
copyBtn.addEventListener('click', function() {
|
||||
navigator.clipboard.writeText(codes.join('\n')).then(function() {
|
||||
copyBtn.textContent = 'Copied ✓';
|
||||
setTimeout(function() { copyBtn.textContent = 'Copy'; }, 1500);
|
||||
}).catch(function() { showToast('Copy failed', 'error'); });
|
||||
});
|
||||
|
||||
var downloadBtn = document.createElement('button');
|
||||
downloadBtn.className = 'btn-sm btn-ghost';
|
||||
downloadBtn.textContent = 'Download .txt';
|
||||
downloadBtn.addEventListener('click', function() {
|
||||
var blob = new Blob(['PedScribe 2FA backup codes\nGenerated: ' + new Date().toISOString() + '\n\n' + codes.join('\n') + '\n'], { type: 'text/plain' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a'); a.href = url; a.download = 'pedscribe-backup-codes.txt'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
var doneBtn = document.createElement('button');
|
||||
doneBtn.className = 'btn-sm btn-primary';
|
||||
doneBtn.textContent = "I've saved them";
|
||||
doneBtn.addEventListener('click', function() { modal.remove(); });
|
||||
|
||||
btnRow.appendChild(copyBtn); btnRow.appendChild(downloadBtn); btnRow.appendChild(doneBtn);
|
||||
box.appendChild(title); box.appendChild(hint); box.appendChild(list); box.appendChild(btnRow);
|
||||
modal.appendChild(box);
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Check registration status — link is hidden by default, shown only when enabled
|
||||
fetch('/api/auth/registration-status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.registrationEnabled) {
|
||||
var link = document.getElementById('show-register');
|
||||
if (link) link.style.display = '';
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
|
||||
console.log('[Auth] ✅ Module ready');
|
||||
});
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
// ============================================================
|
||||
// Global fetch interceptor for auth failures
|
||||
// ============================================================
|
||||
// When the server responds 401 to any /api/ request, the user's
|
||||
// session has been revoked or expired. Previously this would leave
|
||||
// the app UI on screen silently because each fetch handled its own
|
||||
// errors. Now we detect it once, clear local state, and bounce to
|
||||
// the login screen.
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
if (window.__fetchAuthIntercepted) return;
|
||||
window.__fetchAuthIntercepted = true;
|
||||
|
||||
var _fetch = window.fetch.bind(window);
|
||||
var handlingLogout = false;
|
||||
|
||||
// Cross-tab sync: when one tab logs out, all other open tabs drop
|
||||
// their UI within milliseconds instead of waiting for their next
|
||||
// failed fetch. Uses the same BroadcastChannel name across tabs.
|
||||
var bc = null;
|
||||
try { bc = new BroadcastChannel('pedscribe-auth'); } catch (e) { bc = null; }
|
||||
window.__authBroadcast = bc;
|
||||
if (bc) {
|
||||
bc.onmessage = function(ev) {
|
||||
if (ev && ev.data && ev.data.type === 'logout') {
|
||||
handleAuthFailure(null, 'You signed out in another tab.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleAuthFailure(status, reason) {
|
||||
if (handlingLogout) return;
|
||||
handlingLogout = true;
|
||||
try {
|
||||
// Clear any cached token + user info
|
||||
window.AUTH_TOKEN = null;
|
||||
window.CURRENT_USER = null;
|
||||
if (window.SecureStorage) {
|
||||
try { window.SecureStorage.remove('ped_scribe_token'); } catch(e) {}
|
||||
try { window.SecureStorage.remove('ped_scribe_user'); } catch(e) {}
|
||||
try { window.SecureStorage.remove('ped_session_id'); } catch(e) {}
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem('ped_scribe_token');
|
||||
localStorage.removeItem('ped_scribe_user');
|
||||
localStorage.removeItem('ped_session_id');
|
||||
} catch(e) {}
|
||||
|
||||
// Brief user-visible notice before the reload swap
|
||||
try {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(reason || 'Your session has ended. Please sign in again.', 'info');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Reload — the boot flow in auth.js will call /api/auth/me, get a
|
||||
// fresh 401 (no cookie / no token), and show the login screen.
|
||||
setTimeout(function() { window.location.reload(); }, 800);
|
||||
} catch (e) {
|
||||
// Last resort
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
window.fetch = function(input, init) {
|
||||
return _fetch(input, init).then(function(resp) {
|
||||
// Only act on same-origin /api/ calls; don't interfere with login
|
||||
// or logout endpoints themselves (they're allowed to return 401).
|
||||
try {
|
||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
var isApiCall = url.indexOf('/api/') !== -1;
|
||||
var isAuthEndpoint = url.indexOf('/api/auth/login') !== -1
|
||||
|| url.indexOf('/api/auth/register') !== -1
|
||||
|| url.indexOf('/api/auth/logout') !== -1
|
||||
|| url.indexOf('/api/auth/forgot-password') !== -1
|
||||
|| url.indexOf('/api/auth/reset-password') !== -1
|
||||
|| url.indexOf('/api/auth/verify-email') !== -1
|
||||
|| url.indexOf('/api/auth/resend-verification') !== -1
|
||||
|| url.indexOf('/api/auth/oidc') !== -1
|
||||
|| url.indexOf('/api/auth/me') !== -1
|
||||
|| url.indexOf('/api/auth/2fa') !== -1;
|
||||
if (resp.status === 401 && isApiCall && !isAuthEndpoint) {
|
||||
// Only treat as global session failure if the app thinks the user
|
||||
// was logged in (AUTH_TOKEN set OR main-app visible). Avoid
|
||||
// flashing the login screen on public-endpoint 401s during the
|
||||
// pre-login phase.
|
||||
var authed = !!window.AUTH_TOKEN
|
||||
|| document.documentElement.classList.contains('has-session')
|
||||
|| (document.getElementById('main-app') && document.getElementById('main-app').style.display === 'block');
|
||||
if (authed) {
|
||||
handleAuthFailure(resp.status, 'Your session was ended by another device. Please sign in again.');
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
return resp;
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/age-weight.js
|
||||
// BEDSIDE — AGE → WEIGHT (single source for all sub-sections).
|
||||
// Top estimator wiring: #bedside-age, #bedside-formula,
|
||||
// #bedside-weight, #btn-bedside-clear.
|
||||
// ============================================================
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target && e.target.id === 'bedside-age') recomputeFromAge();
|
||||
if (e.target && e.target.id === 'bedside-weight') {
|
||||
e.target.dataset.userTyped = '1';
|
||||
}
|
||||
});
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target && e.target.id === 'bedside-formula') recomputeFromAge(true);
|
||||
});
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest && e.target.closest('#btn-bedside-clear')) {
|
||||
document.getElementById('bedside-age').value = '';
|
||||
var wtField = document.getElementById('bedside-weight');
|
||||
wtField.value = '';
|
||||
delete wtField.dataset.userTyped;
|
||||
var note = document.getElementById('bedside-estimate-note');
|
||||
if (note) note.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Recompute weight from age using the currently selected formula.
|
||||
// If forceFromFormula is true (formula dropdown changed), overwrite even user-typed weight.
|
||||
function recomputeFromAge(forceFromFormula) {
|
||||
var raw = document.getElementById('bedside-age').value;
|
||||
var note = document.getElementById('bedside-estimate-note');
|
||||
if (!raw || !raw.trim()) { if (note) note.innerHTML = ''; return; }
|
||||
var months = window._parseAgeMonths ? window._parseAgeMonths(raw) : null;
|
||||
if (months == null || isNaN(months) || months < 0) {
|
||||
if (note) note.innerHTML = '<span style="color:var(--red);">Could not parse age. Try "3y", "18 months", "15 days".</span>';
|
||||
return;
|
||||
}
|
||||
var formula = document.getElementById('bedside-formula').value;
|
||||
var est = window._PED_MATH.estimateWeightFromAgeMonths(months);
|
||||
var pickedWeight = formula === 'bestguess' ? est.all.bestGuess : est.all.apls;
|
||||
var pickedLabel = formula === 'bestguess' ? bestGuessBand(months) : aplsBand(months);
|
||||
|
||||
var wtField = document.getElementById('bedside-weight');
|
||||
if (forceFromFormula || !wtField.dataset.userTyped) {
|
||||
wtField.value = pickedWeight;
|
||||
delete wtField.dataset.userTyped; // treat as computed again
|
||||
}
|
||||
var ageTxt = months < 12
|
||||
? (Math.round(months * 10) / 10) + ' months'
|
||||
: (Math.round(months / 12 * 10) / 10) + ' years';
|
||||
if (note) note.innerHTML = '<strong>' + pickedWeight + ' kg</strong> ' +
|
||||
'<span style="color:var(--g500);">(' + ageTxt + ', ' + pickedLabel + ')</span> · ' +
|
||||
'APLS: ' + est.all.apls + ' kg · Best Guess: ' + est.all.bestGuess + ' kg. <em>You can override the weight field.</em>';
|
||||
}
|
||||
|
||||
function aplsBand(m) {
|
||||
if (m < 12) return 'APLS 0-12 mo';
|
||||
var y = m / 12;
|
||||
if (y <= 5) return 'APLS 1-5 yr';
|
||||
return 'APLS 6-12 yr';
|
||||
}
|
||||
function bestGuessBand(m) {
|
||||
if (m < 12) return 'Best Guess 1-11 mo';
|
||||
var y = m / 12;
|
||||
if (y <= 5) return 'Best Guess 1-4 yr';
|
||||
return 'Best Guess 5-14 yr';
|
||||
}
|
||||
|
||||
// Top bedside weight is estimator-only. Sections have their own weight fields and
|
||||
// read them directly (no auto-propagation). Expose the reader on window for
|
||||
// safety (some older code paths may look it up).
|
||||
if (typeof window !== 'undefined') {
|
||||
window._getBedsideWeight = function() {
|
||||
var el = document.getElementById('bedside-weight');
|
||||
return el ? parseFloat(el.value) || null : null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/agitation.js
|
||||
// AGITATION — step 1/2/3 pathway, drugs.json-backed.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
// Fallback inline data — mirrors drugs.json agitation section, split into
|
||||
// step-2 (PO/IN) and step-3 (IV/IM) subgroups by the route field.
|
||||
// Display-name column intentionally drops the route suffix from the JSON
|
||||
// name so the table still shows "Lorazepam" / "Midazolam" / "Ketamine".
|
||||
var AGIT_FALLBACK = [
|
||||
// Step 2 — PO / IN
|
||||
{ name: 'Lorazepam PO', display: 'Lorazepam', step: 2, dose_mg_per_kg: 0.05, max_mg: 2, unit: 'mg', route: 'PO', notes: '0.05 mg/kg. May repeat in 30 min.' },
|
||||
{ name: 'Midazolam PO', display: 'Midazolam', step: 2, dose_mg_per_kg: 0.5, max_mg: 10, unit: 'mg', route: 'PO', notes: '0.5 mg/kg (max 10 mg)' },
|
||||
{ name: 'Midazolam IN', display: 'Midazolam', step: 2, dose_mg_per_kg: 0.3, max_mg: 10, unit: 'mg', route: 'IN (5 mg/mL)', notes: '0.3 mg/kg split between nares (max 10 mg)' },
|
||||
{ name: 'Olanzapine (ODT)', display: 'Olanzapine (ODT)', step: 2, dose_mg_per_kg: null, max_mg: null, unit: 'mg', route: 'PO / ODT', notes: 'Age ≥6 yr. Avoid IM + benzo combo (risk of resp depression).', dose_display_if_under_30kg: '2.5-5 mg', dose_display_if_30kg_or_more: '5-10 mg' },
|
||||
{ name: 'Diphenhydramine PO', display: 'Diphenhydramine', step: 2, dose_mg_per_kg: 1, max_mg: 50, unit: 'mg', route: 'PO', notes: '1 mg/kg. Adjunct only; sedating.' },
|
||||
// Step 3 — IV / IM
|
||||
{ name: 'Midazolam IV/IM', display: 'Midazolam', step: 3, dose_mg_per_kg: 0.1, max_mg: 5, unit: 'mg', route: 'IV / IM', notes: '0.05-0.1 mg/kg IV, 0.1-0.15 mg/kg IM (max 10 mg)' },
|
||||
{ name: 'Lorazepam IV/IM', display: 'Lorazepam', step: 3, dose_mg_per_kg: 0.1, max_mg: 4, unit: 'mg', route: 'IV / IM', notes: '0.05-0.1 mg/kg (max 4 mg). May cause resp depression.' },
|
||||
{ name: 'Haloperidol', display: 'Haloperidol', step: 3, dose_mg_per_kg: 0.05, max_mg: null, unit: 'mg', route: 'IM / IV', notes: 'Avoid <3 yr. Risk: QT, EPS, NMS. ECG if repeated.', dose_display_if_under_40kg: '0.025-0.075 mg/kg', dose_display_if_40kg_or_more: '2.5-5 mg' },
|
||||
{ name: 'Olanzapine IM', display: 'Olanzapine', step: 3, dose_mg_per_kg: null, max_mg: null, unit: 'mg', route: 'IM', notes: 'Avoid benzo co-administration (resp depression, hypotension)', dose_display_if_under_40kg: '2.5-5 mg', dose_display_if_40kg_or_more: '5-10 mg' },
|
||||
{ name: 'Ketamine IM', display: 'Ketamine', step: 3, dose_mg_per_kg: 4, max_mg: 500, unit: 'mg', route: 'IM', notes: '4-5 mg/kg IM (rescue for severe excited delirium). Monitor airway.' },
|
||||
{ name: 'Droperidol', display: 'Droperidol', step: 3, dose_mg_per_kg: 0.05, max_mg: null, unit: 'mg', route: 'IM / IV', notes: 'Effective but QT concern — get ECG.', dose_display_prefix: '0.03-0.07 mg/kg' }
|
||||
];
|
||||
|
||||
function agitDrugs() {
|
||||
var s = window._DRUGS && window._DRUGS.sections && window._DRUGS.sections.agitation;
|
||||
if (s && s.drugs && s.drugs.length) {
|
||||
// Merge per-drug hints from fallback (display name, step) when the
|
||||
// JSON doesn't carry them — keeps rendering identical.
|
||||
return s.drugs.map(function(dg) {
|
||||
var fb = AGIT_FALLBACK.filter(function(x) { return x.name === dg.name; })[0] || {};
|
||||
return Object.assign({}, fb, dg);
|
||||
});
|
||||
}
|
||||
return AGIT_FALLBACK;
|
||||
}
|
||||
|
||||
// Render one drug row, handling the agitation-specific display quirks:
|
||||
// weight-conditional dose strings (Olanzapine, Haloperidol) and hand-
|
||||
// computed "prefix = value mg" strings (Droperidol, Haloperidol <40 kg).
|
||||
function agitRowFor(wt, dg) {
|
||||
var dose;
|
||||
if (dg.dose_display_if_under_30kg && dg.dose_display_if_30kg_or_more) {
|
||||
dose = wt < 30 ? dg.dose_display_if_under_30kg : dg.dose_display_if_30kg_or_more;
|
||||
} else if (dg.dose_display_if_under_40kg && dg.dose_display_if_40kg_or_more) {
|
||||
dose = wt < 40 ? dg.dose_display_if_under_40kg + ' = ' + S.d(wt, dg.dose_mg_per_kg) + ' mg' : dg.dose_display_if_40kg_or_more;
|
||||
} else if (dg.dose_display_prefix) {
|
||||
dose = dg.dose_display_prefix + ' = ' + S.d(wt, dg.dose_mg_per_kg) + ' mg';
|
||||
} else if (dg.dose_mg_per_kg != null) {
|
||||
dose = S.dStr(wt, dg.dose_mg_per_kg, dg.max_mg, ' ' + dg.unit);
|
||||
} else {
|
||||
dose = dg.dose_display || '';
|
||||
}
|
||||
return S.drugRow(dg.display || dg.name, dose, dg.route, dg.notes);
|
||||
}
|
||||
|
||||
function calcAgit() {
|
||||
var wt = parseFloat(document.getElementById('agit-weight').value);
|
||||
var el = document.getElementById('agit-result');
|
||||
if (!wt) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
var drugs = agitDrugs();
|
||||
var step2 = drugs.filter(function(dg) { return dg.step === 2; });
|
||||
var step3 = drugs.filter(function(dg) { return dg.step === 3; });
|
||||
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--purple-light);border:1.5px solid var(--purple);margin-bottom:10px;"><strong style="color:var(--purple);">Agitation management — ' + wt + ' kg</strong></div>';
|
||||
html += S.stepBox('#10b981', 'Step 1 — Non-pharmacologic', 'Dim lights, quiet room, familiar caregiver present, reduce stimulation. Rule out hypoglycemia, hypoxia, pain, ↑ICP, toxic ingestion, infection.');
|
||||
html += S.stepBox('#3b82f6', 'Step 2 — Oral / intranasal (cooperative)', 'First-line for moderate agitation if the patient will accept PO/IN.');
|
||||
html += S.drugTable(step2.map(function(dg) { return agitRowFor(wt, dg); }).join(''));
|
||||
html += S.stepBox('#f59e0b', 'Step 3 — IM / IV (severe, uncooperative, or safety risk)', 'Require continuous monitoring. Consider restraints only as last resort.');
|
||||
html += S.drugTable(step3.map(function(dg) { return agitRowFor(wt, dg); }).join(''));
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;margin-top:10px;"><strong>Monitoring:</strong> Continuous SpO2 + HR after parenteral sedation. Have airway equipment, flumazenil, naloxone, and IV access ready.</div>';
|
||||
html += S.ref('AAP Clinical Report on Pediatric Agitation. ACEP Guidelines for Acute Agitation.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-agit-calc' || e.target.closest('#btn-agit-calc')) calcAgit();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/airway.js
|
||||
// AIRWAY / RSI.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function calcAirway() {
|
||||
var wt = parseFloat(document.getElementById('airway-weight').value);
|
||||
var age = parseFloat(document.getElementById('airway-age').value);
|
||||
var el = document.getElementById('airway-result');
|
||||
if (!wt) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
|
||||
// Equipment sizing
|
||||
var ettUncuff = age ? Math.round(((age/4) + 4) * 2) / 2 : null;
|
||||
var ettCuff = age ? Math.round(((age/4) + 3.5) * 2) / 2 : null;
|
||||
var ettDepth = ettUncuff ? Math.round(ettUncuff * 3 * 10) / 10 : null;
|
||||
var bladeSize = age != null ? (age < 1 ? 'Miller 0-1' : age < 2 ? 'Miller 1 / Mac 1' : age < 8 ? 'Miller 2 / Mac 2' : 'Miller/Mac 3') : '—';
|
||||
var lmaSize = wt < 5 ? '1' : wt < 10 ? '1.5' : wt < 20 ? '2' : wt < 30 ? '2.5' : wt < 50 ? '3' : wt < 70 ? '4' : '5';
|
||||
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong style="color:var(--blue-dark);">RSI doses & equipment — ' + wt + ' kg' + (age ? ', ~' + age + ' yr' : '') + '</strong></div>';
|
||||
|
||||
// Pre-medication
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:10px 0 4px;">Pre-medication (optional)</h5>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Atropine', S.dStr(wt, 0.02, 1, ' mg') + ' (min 0.1 mg)', 'IV', 'Consider <1 yr or if using succinylcholine, or bradycardia risk') +
|
||||
S.drugRow('Lidocaine 2%', S.dStr(wt, 1.5, 100, ' mg'), 'IV over 1 min', 'For ↑ICP, asthma; give 2-3 min pre-induction') +
|
||||
S.drugRow('Fentanyl', S.dStr(wt, 2, 150, ' mcg') + ' (2-3 mcg/kg)', 'IV over 1 min', 'Blunts sympathetic response; avoid if hypotensive')
|
||||
);
|
||||
|
||||
// Induction
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 4px;">Induction</h5>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Ketamine', S.dStr(wt, 1.5, 150, ' mg') + ' (1-2 mg/kg)', 'IV', 'Preserves BP. First-line for shock, asthma, sepsis') +
|
||||
S.drugRow('Etomidate', S.dStr(wt, 0.3, 30, ' mg'), 'IV', 'Hemodynamically neutral. Avoid in septic shock (adrenal suppression)') +
|
||||
S.drugRow('Propofol', S.dStr(wt, 2, 200, ' mg') + ' (1-2 mg/kg)', 'IV', 'Causes hypotension. Avoid in shock') +
|
||||
S.drugRow('Midazolam', S.dStr(wt, 0.2, 10, ' mg'), 'IV', 'Less optimal induction — slower onset')
|
||||
);
|
||||
|
||||
// Paralytics
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 4px;">Paralytics</h5>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Rocuronium', S.dStr(wt, 1.2, 100, ' mg') + ' (1-1.2 mg/kg)', 'IV', '30-60 sec onset; 30-45 min duration. First-line non-depolarizing.') +
|
||||
S.drugRow('Succinylcholine', S.dStr(wt, (wt < 10 ? 2 : 1.5), 150, ' mg'), 'IV', '<10kg: 2 mg/kg; ≥10kg: 1.5 mg/kg. Avoid in burns/crush/hyperK/MH/NM dz. IM option: 4 mg/kg.') +
|
||||
S.drugRow('Vecuronium', S.dStr(wt, 0.1, 10, ' mg'), 'IV', '2-3 min onset; longer duration than roc')
|
||||
);
|
||||
|
||||
// Post-intubation sedation
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 4px;">Maintenance sedation / analgesia</h5>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Midazolam gtt', S.dStr(wt, 0.05, 2, ' mg') + '-' + S.dStr(wt, 0.2, 10, ' mg') + '/hr', 'IV infusion', '0.05-0.2 mg/kg/hr') +
|
||||
S.drugRow('Fentanyl gtt', S.dStr(wt, 1, 100, ' mcg') + '-' + S.dStr(wt, 4, 200, ' mcg') + '/hr', 'IV infusion', '1-4 mcg/kg/hr') +
|
||||
S.drugRow('Ketamine gtt', S.dStr(wt, 0.5, 30, ' mg') + '-' + S.dStr(wt, 2, 100, ' mg') + '/hr', 'IV infusion', '0.5-2 mg/kg/hr. Good for asthma') +
|
||||
S.drugRow('Dexmedetomidine gtt', '0.2-1.4 mcg/kg/hr', 'IV infusion', 'No resp depression; causes bradycardia + hypotension') +
|
||||
S.drugRow('Rocuronium gtt', '10-12 mcg/kg/min', 'IV infusion', 'If continued paralysis needed — only with adequate sedation')
|
||||
);
|
||||
|
||||
// Equipment
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 4px;">Equipment sizing</h5>';
|
||||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;font-size:12px;">';
|
||||
if (age != null) {
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;"><strong>ETT (uncuffed):</strong> ' + ettUncuff + ' mm<br><strong>ETT (cuffed):</strong> ' + ettCuff + ' mm<br><strong>Depth at lip:</strong> ~' + ettDepth + ' cm</div>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;"><strong>Laryngoscope blade:</strong><br>' + bladeSize + '</div>';
|
||||
} else {
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;"><em>Enter age for ETT/blade sizing.</em><br>ETT (uncuffed) = (age/4) + 4<br>ETT (cuffed) = (age/4) + 3.5<br>Depth = ETT size × 3</div>';
|
||||
}
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;"><strong>LMA size:</strong> ' + lmaSize + '<br>OPA = corner mouth → angle mandible<br>NPA = tip nose → tragus</div>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;"><strong>Suction catheter (Fr):</strong> ETT size × 2<br><strong>NG tube:</strong> ETT × 2<br><strong>Chest tube:</strong> 4 × ETT</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Ventilator starting settings
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 4px;">Ventilator — starting settings</h5>';
|
||||
html += '<div style="padding:10px;background:var(--blue-light);border-radius:6px;font-size:12px;color:var(--g700);">';
|
||||
html += '<strong>Mode:</strong> Volume or pressure control<br>';
|
||||
html += '<strong>Tidal volume:</strong> 6-8 mL/kg (= ' + S.d(wt, 6) + '-' + S.d(wt, 8) + ' mL). Use lower (4-6 mL/kg) for ARDS.<br>';
|
||||
html += '<strong>Rate:</strong> Infant 25-30, Child 15-25, Adolescent 12-16<br>';
|
||||
html += '<strong>PEEP:</strong> 5 cmH2O (↑ for oxygenation problems)<br>';
|
||||
html += '<strong>FiO2:</strong> Start 100%, wean to SpO2 92-97%<br>';
|
||||
html += '<strong>I:E ratio:</strong> 1:2 (1:3-4 for obstructive disease)';
|
||||
html += '</div>';
|
||||
|
||||
html += S.ref('Harriet Lane Handbook 23rd ed / PALS 2020. Always confirm doses against institutional protocols.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-airway-calc' || e.target.closest('#btn-airway-calc')) calcAirway();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/anaphylaxis.js
|
||||
// ANAPHYLAXIS MANAGEMENT.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function d(wt, mgPerKg, max) { var v = Math.round(wt * mgPerKg * 100) / 100; return max ? Math.min(v, max) : v; }
|
||||
|
||||
// Lookup a drug entry (by name) from window._DRUGS.sections.anaphylaxis,
|
||||
// falling back to a hardcoded record so rendering stays identical if the
|
||||
// JSON fetch hasn't resolved (or failed). Returns an object exposing the
|
||||
// per-kg + max fields needed by S.dStr.
|
||||
var ANAPH_FALLBACK = {
|
||||
'Epinephrine IM': { dose_mg_per_kg: 0.01, max_mg: 0.5, unit: 'mg' },
|
||||
'Normal saline bolus': { dose_mg_per_kg: 20, max_mg: null, unit: 'mL' },
|
||||
'Diphenhydramine': { dose_mg_per_kg: 1.25, max_mg: 50, unit: 'mg' },
|
||||
'Ranitidine': { dose_mg_per_kg: 1, max_mg: 50, unit: 'mg' },
|
||||
'Dexamethasone': { dose_mg_per_kg: 0.6, max_mg: 16, unit: 'mg' },
|
||||
'Methylprednisolone': { dose_mg_per_kg: 2, max_mg: 125, unit: 'mg' },
|
||||
'Glucagon': { dose_mg_per_kg: 0.02, max_mg: 1, unit: 'mg' }
|
||||
};
|
||||
|
||||
function anaphDrug(name) {
|
||||
var s = window._DRUGS && window._DRUGS.sections && window._DRUGS.sections.anaphylaxis;
|
||||
if (s && s.drugs) {
|
||||
var hit = s.drugs.filter(function(x) { return x.name === name; })[0];
|
||||
if (hit) return hit;
|
||||
}
|
||||
return ANAPH_FALLBACK[name] || {};
|
||||
}
|
||||
|
||||
function calcAnaphylaxis() {
|
||||
var wt = parseFloat(document.getElementById('anaph-weight').value);
|
||||
var age = document.getElementById('anaph-age') ? document.getElementById('anaph-age').value : null; // kept for parity; not currently branched on
|
||||
void age;
|
||||
var resultDiv = document.getElementById('anaph-result');
|
||||
if (!wt) { resultDiv.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
|
||||
var epi = anaphDrug('Epinephrine IM');
|
||||
var fluids = anaphDrug('Normal saline bolus');
|
||||
var dph = anaphDrug('Diphenhydramine');
|
||||
var rnt = anaphDrug('Ranitidine');
|
||||
var dex = anaphDrug('Dexamethasone');
|
||||
var mpn = anaphDrug('Methylprednisolone');
|
||||
var glc = anaphDrug('Glucagon');
|
||||
|
||||
var epiDose = d(wt, epi.dose_mg_per_kg, epi.max_mg);
|
||||
var epiVol = Math.round(epiDose * 10) / 10; // 1:1000 = 1 mg/mL
|
||||
var autoInjector = wt < 10 ? 'Draw up manually' : wt <= 25 ? 'EpiPen Jr / Auvi-Q 0.15 mg' : 'EpiPen / Auvi-Q 0.3 mg';
|
||||
|
||||
var html = '<div style="padding:14px;border-radius:10px;background:#fee2e2;border:2px solid #ef4444;margin-bottom:16px;">';
|
||||
html += '<div style="font-size:18px;font-weight:700;color:#dc2626;margin-bottom:6px;"><i class="fas fa-triangle-exclamation"></i> STEP 1: Epinephrine IM — GIVE IMMEDIATELY</div>';
|
||||
html += '<div style="font-size:15px;font-weight:600;color:var(--g800);margin-bottom:4px;">Epinephrine 1:1000 (1 mg/mL): <span style="color:#dc2626;">' + epiDose + ' mg (' + epiVol + ' mL)</span> IM to lateral thigh</div>';
|
||||
html += '<div style="font-size:13px;color:var(--g600);">' + epi.dose_mg_per_kg + ' mg/kg (max ' + epi.max_mg + ' mg) | Auto-injector: ' + autoInjector + '</div>';
|
||||
html += '<div style="font-size:12px;color:#991b1b;margin-top:6px;">May repeat every 5-15 minutes if symptoms persist. No contraindications in anaphylaxis.</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;margin-bottom:12px;"><table style="width:100%;min-width:560px;border-collapse:collapse;font-size:13px;"><thead><tr style="background:var(--g100);"><th style="text-align:left;padding:6px 8px;">Step</th><th style="padding:6px 8px;">Drug</th><th style="padding:6px 8px;">Dose</th><th style="padding:6px 8px;">Route</th><th style="padding:6px 8px;">Notes</th></tr></thead><tbody>';
|
||||
|
||||
html += '<tr><td style="font-weight:600;color:var(--blue);">STEP 2</td><td style="font-weight:600;">Position</td><td>Supine + legs elevated</td><td>—</td><td style="font-size:12px;color:var(--g500);">If dyspneic: sitting position. If vomiting: recovery position</td></tr>';
|
||||
html += '<tr><td style="font-weight:600;color:var(--blue);">STEP 3</td><td style="font-weight:600;">O2</td><td>High flow 10-15 L/min</td><td>Face mask</td><td style="font-size:12px;color:var(--g500);">100% O2. Prepare for airway management</td></tr>';
|
||||
html += '<tr><td style="font-weight:600;color:var(--blue);">STEP 4</td><td style="font-weight:600;">IV fluids</td><td>NS ' + S.dStr(wt, fluids.dose_mg_per_kg, fluids.max_mg, ' mL') + ' bolus</td><td>IV/IO</td><td style="font-size:12px;color:var(--g500);">Repeat up to 60 mL/kg for hypotension</td></tr>';
|
||||
html += '<tr><td style="font-weight:600;color:var(--blue);">STEP 5</td><td style="font-weight:600;">Diphenhydramine</td><td>' + S.dStr(wt, dph.dose_mg_per_kg, dph.max_mg) + '</td><td>IV/IM/PO</td><td style="font-size:12px;color:var(--g500);">H1 blocker. NOT first-line — adjunct only</td></tr>';
|
||||
html += '<tr><td></td><td style="font-weight:600;">Ranitidine</td><td>' + S.dStr(wt, rnt.dose_mg_per_kg, rnt.max_mg) + '</td><td>IV over 5 min</td><td style="font-size:12px;color:var(--g500);">H2 blocker. Optional adjunct</td></tr>';
|
||||
html += '<tr><td style="font-weight:600;color:var(--blue);">STEP 6</td><td style="font-weight:600;">Dexamethasone</td><td>' + S.dStr(wt, dex.dose_mg_per_kg, dex.max_mg) + '</td><td>IV/IM/PO</td><td style="font-size:12px;color:var(--g500);">Prevents biphasic reaction (4-6 hrs later)</td></tr>';
|
||||
html += '<tr><td></td><td style="font-weight:600;">Methylprednisolone</td><td>' + S.dStr(wt, mpn.dose_mg_per_kg, mpn.max_mg) + '</td><td>IV</td><td style="font-size:12px;color:var(--g500);">Alternative steroid</td></tr>';
|
||||
|
||||
html += '<tr style="background:#fef3c7;"><td style="font-weight:600;color:#dc2626;">IF REFRACTORY</td><td style="font-weight:600;">Epinephrine gtt</td><td>0.1-1 mcg/kg/min</td><td>IV infusion</td><td style="font-size:12px;color:var(--g500);">For persistent hypotension despite fluids + IM epi</td></tr>';
|
||||
html += '<tr style="background:#fef3c7;"><td></td><td style="font-weight:600;">Glucagon</td><td>' + S.dStr(wt, glc.dose_mg_per_kg, glc.max_mg) + '</td><td>IV/IM</td><td style="font-size:12px;color:var(--g500);">For patients on beta-blockers not responding to epi</td></tr>';
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
html += '<div style="padding:10px 12px;background:var(--amber-light);border-radius:6px;font-size:12px;color:#92400e;"><strong>Observe minimum 4-6 hours</strong> after last dose of epinephrine (biphasic reactions occur in 5-20% of cases). Discharge with EpiPen prescription and anaphylaxis action plan. Refer to allergist.</div>';
|
||||
html += '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">ASCIA Anaphylaxis Guidelines 2021. WAO Anaphylaxis Guidance 2020. AAP/ACAAI Practice Parameters.</p>';
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-anaph-calc' || e.target.closest('#btn-anaph-calc')) calcAnaphylaxis();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/antiemetics.js
|
||||
// ANTIEMETICS — drugs.json-backed.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
// Fallback inline data — used if /data/drugs.json hasn't loaded yet.
|
||||
// Must match drugs.json shape for the antiemetics section.
|
||||
var EMET_FALLBACK = [
|
||||
{ name: 'Ondansetron', dose_mg_per_kg: 0.15, max_mg: 8, unit: 'mg', route: 'PO / ODT / IV', notes: 'First-line. Max single 8 mg. Repeat q8h. QT prolongation — avoid with other QT drugs. <6 mo: limited data.', weight_band_dose: { under_15kg: '2 mg', '15_to_30kg': '4 mg', '30kg_or_more': '8 mg' } },
|
||||
{ name: 'Metoclopramide', dose_mg_per_kg: 0.15, max_mg: 10, unit: 'mg', route: 'IV / IM / PO', notes: '0.1-0.15 mg/kg (max 10 mg). Give with diphenhydramine to prevent EPS / dystonia.' },
|
||||
{ name: 'Dimenhydrinate', dose_mg_per_kg: 1.25, max_mg: 50, unit: 'mg', route: 'PO / IV / IM / PR', notes: '1.25 mg/kg (max 50 mg) q6h. ≥2 yr.' },
|
||||
{ name: 'Diphenhydramine', dose_mg_per_kg: 1, max_mg: 50, unit: 'mg', route: 'PO / IV / IM', notes: '1 mg/kg (max 50 mg) q6h. Adjunct, sedating.' },
|
||||
{ name: 'Promethazine', dose_mg_per_kg: 0.25, max_mg: 25, unit: 'mg', route: 'PO / IV / IM', notes: '0.25-1 mg/kg. <strong>CONTRAINDICATED <2 yr</strong> (resp depression). Tissue injury if IV extrav.' },
|
||||
{ name: 'Dexamethasone', dose_mg_per_kg: 0.15, max_mg: 10, unit: 'mg', route: 'IV / PO', notes: 'Adjunct, esp. chemo-induced. 0.15 mg/kg (max 10 mg).' },
|
||||
{ name: 'Scopolamine patch', dose_mg_per_kg: null, max_mg: null, unit: 'mg', dose_display: '1.5 mg patch', route: 'Transdermal', notes: '≥12 yr. Motion sickness. Apply 4h before exposure.' }
|
||||
];
|
||||
|
||||
// Prefer JSON-backed data if loaded; fall back to the inline list above.
|
||||
function emetDrugs() {
|
||||
var s = window._DRUGS && window._DRUGS.sections && window._DRUGS.sections.antiemetics;
|
||||
return (s && s.drugs && s.drugs.length) ? s.drugs : EMET_FALLBACK;
|
||||
}
|
||||
|
||||
// Iterate the drug list to produce S.drugRow(...) output, handling the
|
||||
// special cases (Ondansetron weight-band prefix, Scopolamine fixed dose).
|
||||
function emetRows(wt, drugs) {
|
||||
return drugs.map(function(dg) {
|
||||
var dose;
|
||||
if (dg.name === 'Ondansetron' && dg.weight_band_dose) {
|
||||
var bd = dg.weight_band_dose;
|
||||
var band = wt < 15 ? bd.under_15kg : wt < 30 ? bd['15_to_30kg'] : bd['30kg_or_more'];
|
||||
dose = band + ' (weight band) OR ' + S.dStr(wt, dg.dose_mg_per_kg, dg.max_mg, ' ' + dg.unit) + ' (' + dg.dose_mg_per_kg + ' ' + dg.unit + '/kg)';
|
||||
} else if (dg.dose_display) {
|
||||
dose = dg.dose_display;
|
||||
} else {
|
||||
dose = S.dStr(wt, dg.dose_mg_per_kg, dg.max_mg, ' ' + dg.unit);
|
||||
}
|
||||
return S.drugRow(dg.name, dose, dg.route, dg.notes);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function calcEmet() {
|
||||
var wt = parseFloat(document.getElementById('emet-weight').value);
|
||||
var el = document.getElementById('emet-result');
|
||||
if (!wt) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong style="color:var(--blue-dark);">Antiemetic doses — ' + wt + ' kg</strong></div>';
|
||||
html += S.drugTable(emetRows(wt, emetDrugs()));
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;margin-top:10px;"><strong>Pearls:</strong> Ondansetron is first-line in ED for acute gastroenteritis vomiting — single PO/ODT dose increases oral rehydration success. Avoid anticholinergics + opioid combinations. Check QTc if stacking ondansetron + other QT drugs.</div>';
|
||||
html += S.ref('AAP gastroenteritis guidance; WHO essential medicines; Lexicomp.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-emet-calc' || e.target.closest('#btn-emet-calc')) calcEmet();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/antimicrobials.js
|
||||
// ANTIMICROBIALS (empiric, by age + infection type).
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
var REG = {
|
||||
neonate: {
|
||||
sepsis: { first: 'Ampicillin + gentamicin', alt: 'Add cefotaxime if meningitis suspected or gram-neg concern', duration: '7-10 days; 21 days if confirmed meningitis', notes: 'Cover GBS, E. coli, Listeria. Add acyclovir if HSV risk (maternal lesions, vesicles, seizures).' },
|
||||
meningitis: { first: 'Ampicillin + cefotaxime (or gentamicin) + acyclovir', alt: 'Add vancomycin if gram-positive cocci', duration: '14-21 days depending on organism', notes: 'HSV coverage essential. LP + HSV PCR.' },
|
||||
pna: { first: 'Ampicillin + gentamicin', alt: 'Add cefotaxime for gram-neg', duration: '7-10 days', notes: 'Same as sepsis coverage.' },
|
||||
uti: { first: 'Ampicillin + gentamicin', alt: 'Cefotaxime if sensitivities known', duration: '10-14 days', notes: 'Evaluate for sepsis. Renal US and VCUG workup.' },
|
||||
skin: { first: 'Ampicillin + gentamicin', alt: 'Add vancomycin if MRSA risk', duration: '7-10 days', notes: 'Omphalitis, mastitis — broad coverage.' },
|
||||
ent: { first: 'Discuss with infectious diseases', alt: '—', duration: '—', notes: 'Rare in neonates. Any ear/throat infection warrants sepsis eval.' },
|
||||
neutropenic: { first: 'Cefepime or piperacillin-tazobactam', alt: 'Add vancomycin if indwelling line, severe mucositis, or MRSA', duration: 'Until ANC recovery + afebrile ≥48h', notes: 'Oncology/ID consult.' },
|
||||
ic: { first: 'Ampicillin + gentamicin + metronidazole', alt: 'Or piperacillin-tazobactam', duration: '7-14 days', notes: 'NEC: add metronidazole/clindamycin for anaerobes.' },
|
||||
bone: { first: 'Ampicillin + gentamicin', alt: 'Vancomycin if MRSA risk', duration: '3-6 weeks (IV then PO)', notes: 'Usually hematogenous. S. aureus, GBS, E. coli.' }
|
||||
},
|
||||
infant: {
|
||||
sepsis: { first: 'Ampicillin + ceftriaxone (or gentamicin)', alt: '+ vancomycin if MRSA / severe', duration: '7-10 days', notes: 'Add acyclovir if HSV suspected (<6 wks). Fever in <90 days requires workup.' },
|
||||
meningitis: { first: 'Ceftriaxone + vancomycin (± ampicillin if <6 wks for Listeria)', alt: '+ acyclovir if HSV', duration: '10-14 days (longer for Listeria/gram-neg)', notes: 'Dexamethasone if H. influenzae suspected.' },
|
||||
pna: { first: 'Ampicillin (or ceftriaxone)', alt: 'Add azithromycin if atypical', duration: '7-10 days', notes: 'S. pneumoniae most common. RSV/viral often primary.' },
|
||||
uti: { first: 'Ceftriaxone or cefotaxime', alt: 'Ampicillin + gentamicin', duration: '10-14 days', notes: 'US kidney/bladder if first febrile UTI.' },
|
||||
skin: { first: 'Cefazolin or clindamycin', alt: 'Vancomycin if MRSA risk', duration: '7-10 days', notes: 'Consider MRSA if purulent or local resistance >10%.' },
|
||||
ent: { first: 'Amoxicillin (90 mg/kg/day) for AOM', alt: 'Amox-clav if recent abx/treatment failure', duration: 'AOM 10 days <2 yr; 7 days ≥2 yr', notes: 'Observe mild AOM if >6 mo and no severe features.' },
|
||||
neutropenic: { first: 'Cefepime or piperacillin-tazobactam', alt: '+ vancomycin for severe/mucositis/line', duration: 'Until ANC recovery + afebrile', notes: 'Oncology/ID consult.' },
|
||||
ic: { first: 'Ceftriaxone + metronidazole', alt: 'Piperacillin-tazobactam', duration: '5-7 days (uncomplicated), longer for complicated', notes: 'Appendicitis — surgical consult.' },
|
||||
bone: { first: 'Cefazolin ± vancomycin', alt: 'Clindamycin', duration: '3-4 weeks (IV then PO transition)', notes: 'S. aureus most common. Kingella in <4 yr.' }
|
||||
},
|
||||
child: {
|
||||
sepsis: { first: 'Ceftriaxone + vancomycin', alt: 'Piperacillin-tazobactam if intra-abdominal', duration: '7-14 days', notes: 'Expand if specific source. Add antifungal if prolonged neutropenia.' },
|
||||
meningitis: { first: 'Ceftriaxone + vancomycin', alt: '+ dexamethasone (Hib coverage)', duration: '10-14 days', notes: 'Lumbar puncture. Dexamethasone 0.15 mg/kg q6h x 4 days.' },
|
||||
pna: { first: 'Ampicillin OR amoxicillin (high dose)', alt: 'Ceftriaxone if hospitalized; add azithromycin for atypical', duration: '5-7 days (uncomplicated)', notes: 'Mycoplasma if >5 yr. Consider viral causes.' },
|
||||
uti: { first: 'Cephalexin or TMP-SMX (PO)', alt: 'Ceftriaxone if ill', duration: '7-10 days (10-14 if pyelo)', notes: 'Culture-guided de-escalation.' },
|
||||
skin: { first: 'Cephalexin or clindamycin', alt: 'TMP-SMX or doxycycline if MRSA', duration: '5-10 days', notes: 'Abscess — incision & drainage primary tx.' },
|
||||
ent: { first: 'Amoxicillin 90 mg/kg/day (AOM); Penicillin V for strep pharyngitis', alt: 'Amox-clav; cephalexin if non-anaphylactic PCN allergy', duration: 'AOM 5-10 days; strep 10 days', notes: 'Strep pharyngitis dose: PCN V 250 mg BID-TID (<27 kg) or 500 mg BID (≥27 kg).' },
|
||||
neutropenic: { first: 'Cefepime OR piperacillin-tazobactam', alt: '+ vancomycin', duration: 'Until ANC recovery + afebrile', notes: 'Add empiric antifungal if fever >4-7 days.' },
|
||||
ic: { first: 'Ceftriaxone + metronidazole', alt: 'Piperacillin-tazobactam', duration: '5-7 days (uncomplicated appendicitis)', notes: 'Surgical consult.' },
|
||||
bone: { first: 'Cefazolin ± vancomycin', alt: 'Clindamycin', duration: '3-4 weeks total', notes: 'S. aureus, group A strep. Consider MRSA if severe.' }
|
||||
}
|
||||
};
|
||||
|
||||
function lookupAbx() {
|
||||
var age = document.getElementById('abx-age').value;
|
||||
var inf = document.getElementById('abx-infection').value;
|
||||
var el = document.getElementById('abx-result');
|
||||
var r = REG[age] && REG[age][inf];
|
||||
if (!r) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">No regimen found.</p>'; return; }
|
||||
var ageLbl = { neonate: 'Neonate (0-28 d)', infant: 'Infant (1-3 mo)', child: 'Child (>3 mo)' }[age];
|
||||
var infLbl = document.querySelector('#abx-infection option[value="' + inf + '"]').textContent;
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong style="color:var(--blue-dark);">' + ageLbl + ' — ' + infLbl + '</strong></div>';
|
||||
html += '<div style="padding:12px;background:#d1fae5;border-radius:6px;margin-bottom:10px;"><strong style="color:#065f46;">First-line:</strong> ' + r.first + '</div>';
|
||||
html += '<div style="padding:12px;background:var(--g50);border-radius:6px;margin-bottom:10px;"><strong>Alternative / add:</strong> ' + r.alt + '</div>';
|
||||
html += '<div style="padding:12px;background:var(--g50);border-radius:6px;margin-bottom:10px;"><strong>Duration:</strong> ' + r.duration + '</div>';
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;"><strong>Notes:</strong> ' + r.notes + '</div>';
|
||||
html += S.ref('AAP Red Book, IDSA guidelines, Harriet Lane. Always tailor to local resistance patterns and culture results.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-abx-lookup' || e.target.closest('#btn-abx-lookup')) lookupAbx();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/burns.js
|
||||
// BURNS — Lund-Browder body parts + Parkland formula.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
// Lund-Browder age-adjusted region percentages. Regions marked "*" change with age.
|
||||
// Column order: infant, young (1-5), child (5-10), adol (10-15), adult (>15).
|
||||
var LB = {
|
||||
head: { label: 'Head', vals: [18, 13, 11, 9, 7], ageSensitive: true },
|
||||
neck: { label: 'Neck', vals: [2, 2, 2, 2, 2] },
|
||||
ant_trunk: { label: 'Anterior trunk', vals: [13, 13, 13, 13, 13] },
|
||||
post_trunk:{ label: 'Posterior trunk', vals: [13, 13, 13, 13, 13] },
|
||||
r_buttock: { label: 'Right buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||||
l_buttock: { label: 'Left buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||||
genital: { label: 'Genitalia', vals: [1, 1, 1, 1, 1] },
|
||||
r_uparm: { label: 'R upper arm', vals: [4, 4, 4, 4, 4] },
|
||||
l_uparm: { label: 'L upper arm', vals: [4, 4, 4, 4, 4] },
|
||||
r_forearm: { label: 'R forearm', vals: [3, 3, 3, 3, 3] },
|
||||
l_forearm: { label: 'L forearm', vals: [3, 3, 3, 3, 3] },
|
||||
r_hand: { label: 'R hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||||
l_hand: { label: 'L hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||||
r_thigh: { label: 'R thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
|
||||
l_thigh: { label: 'L thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
|
||||
r_leg: { label: 'R lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
|
||||
l_leg: { label: 'L lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
|
||||
r_foot: { label: 'R foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
|
||||
l_foot: { label: 'L foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] }
|
||||
};
|
||||
var AGE_KEYS = ['infant','young','child','adol','adult'];
|
||||
|
||||
function ageIdx() { return Math.max(0, AGE_KEYS.indexOf(document.getElementById('burn-age').value)); }
|
||||
|
||||
function renderBodyParts() {
|
||||
var wrap = document.getElementById('burn-bodyparts-wrapper');
|
||||
if (!wrap) return;
|
||||
var idx = ageIdx();
|
||||
var html = '<div style="background:var(--g50);border-radius:8px;padding:10px 12px;">';
|
||||
html += '<div style="font-size:12px;color:var(--g600);margin-bottom:8px;line-height:1.4;">For each involved body region, enter <strong>% of that region burned (2° or deeper)</strong>. Leave 0 if uninvolved. TBSA = sum of (region size × % involvement).</div>';
|
||||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:6px;">';
|
||||
Object.keys(LB).forEach(function(key) {
|
||||
var r = LB[key];
|
||||
var max = r.vals[idx];
|
||||
var isAgeDep = r.ageSensitive;
|
||||
html += '<div style="display:flex;align-items:center;gap:8px;padding:4px 8px;background:white;border-radius:6px;border:1px solid var(--g200);">' +
|
||||
'<label style="flex:1;font-size:12px;color:var(--g700);">' + r.label + ' <span style="color:var(--g400);">(' + max + '%' + (isAgeDep ? '<span title="age-adjusted">*</span>' : '') + ')</span></label>' +
|
||||
'<input type="number" min="0" max="100" step="5" value="0" data-burn-region="' + key + '" data-burn-max="' + max + '" style="width:68px;padding:3px 6px;border:1px solid var(--g300);border-radius:4px;font-size:12px;text-align:right;"> %' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
wrap.innerHTML = html;
|
||||
updateLive();
|
||||
}
|
||||
|
||||
function resetParts() {
|
||||
var inputs = document.querySelectorAll('[data-burn-region]');
|
||||
inputs.forEach(function(i) { i.value = 0; });
|
||||
document.getElementById('burn-tbsa').value = '';
|
||||
updateLive();
|
||||
}
|
||||
|
||||
function computeTbsa() {
|
||||
var idx = ageIdx();
|
||||
var total = 0;
|
||||
Object.keys(LB).forEach(function(key) {
|
||||
var el = document.querySelector('[data-burn-region="' + key + '"]');
|
||||
if (!el) return;
|
||||
var pct = parseFloat(el.value) || 0;
|
||||
if (pct < 0) pct = 0; if (pct > 100) pct = 100;
|
||||
total += LB[key].vals[idx] * (pct / 100);
|
||||
});
|
||||
return Math.round(total * 10) / 10;
|
||||
}
|
||||
|
||||
function updateLive() {
|
||||
var t = computeTbsa();
|
||||
var live = document.getElementById('burn-tbsa-live');
|
||||
if (live) live.textContent = t > 0 ? 'Computed TBSA: ' + t + '%' : '';
|
||||
}
|
||||
|
||||
function calcBurn() {
|
||||
var wt = parseFloat(document.getElementById('burn-weight').value);
|
||||
var override = document.getElementById('burn-tbsa').value;
|
||||
var tbsa = override !== '' ? parseFloat(override) : computeTbsa();
|
||||
var el = document.getElementById('burn-result');
|
||||
if (!wt) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
if (!tbsa || tbsa <= 0) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter % involvement per region, or override TBSA.</p>'; return; }
|
||||
var total = Math.round(4 * wt * tbsa);
|
||||
var first8 = Math.round(total / 2);
|
||||
var rateFirst = Math.round(first8 / 8);
|
||||
var next16 = total - first8;
|
||||
var rateNext = Math.round(next16 / 16);
|
||||
var maint = wt <= 10 ? wt * 4 : wt <= 20 ? 40 + (wt - 10) * 2 : 60 + (wt - 20);
|
||||
maint = Math.round(maint);
|
||||
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:#fee2e2;border:1.5px solid #ef4444;margin-bottom:10px;"><strong style="color:#dc2626;">Burn fluid resuscitation — ' + wt + ' kg, ' + tbsa + '% TBSA (2° or deeper)</strong></div>';
|
||||
html += '<div style="padding:12px;background:#fef2f2;border-radius:6px;margin-bottom:10px;"><strong>Parkland formula:</strong> 4 mL × kg × %TBSA = <strong>' + total + ' mL LR over 24 hours</strong>' +
|
||||
'<br> <strong>First 8 h</strong> (from time of burn): ' + first8 + ' mL (~<strong>' + rateFirst + ' mL/hr</strong>)' +
|
||||
'<br> <strong>Next 16 h</strong>: ' + next16 + ' mL (~<strong>' + rateNext + ' mL/hr</strong>)</div>';
|
||||
html += '<div style="padding:12px;background:var(--g50);border-radius:6px;margin-bottom:10px;"><strong>Plus maintenance fluids (4-2-1):</strong> ' + maint + ' mL/hr (D5 1/2NS ± 20 mEq KCl/L once UOP established). Consider dextrose in children <30 kg.</div>';
|
||||
html += '<div style="padding:12px;background:var(--g50);border-radius:6px;margin-bottom:10px;"><strong>Titrate to UOP:</strong> target 1-2 mL/kg/hr (infants / children), 0.5-1 mL/kg/hr (adolescents). <strong>Clinical response trumps formula.</strong></div>';
|
||||
|
||||
// Breakdown of what regions contributed
|
||||
var idx = ageIdx();
|
||||
var breakdown = '';
|
||||
Object.keys(LB).forEach(function(key) {
|
||||
var el2 = document.querySelector('[data-burn-region="' + key + '"]');
|
||||
if (!el2) return;
|
||||
var pct = parseFloat(el2.value) || 0;
|
||||
if (pct > 0) {
|
||||
var contrib = Math.round(LB[key].vals[idx] * pct / 100 * 10) / 10;
|
||||
breakdown += '<div>' + LB[key].label + ': ' + pct + '% of ' + LB[key].vals[idx] + '% = ' + contrib + '%</div>';
|
||||
}
|
||||
});
|
||||
if (breakdown) {
|
||||
html += '<details style="margin-bottom:10px;"><summary style="cursor:pointer;font-size:13px;font-weight:600;color:var(--g700);">Region breakdown</summary><div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;margin-top:6px;">' + breakdown + '</div></details>';
|
||||
}
|
||||
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Other pearls</h5>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;">';
|
||||
html += '<strong>Rule of palm:</strong> Patient\'s palm + fingers ≈ 1% TBSA — good for scattered burns.<br>';
|
||||
html += '<strong>First-degree burns DO NOT count</strong> toward TBSA or Parkland.<br>';
|
||||
html += '<strong>Analgesia:</strong> Morphine 0.05-0.1 mg/kg IV q2h, or fentanyl 1-2 mcg/kg IV q30-60 min.<br>';
|
||||
html += '<strong>Tetanus</strong> prophylaxis if indicated. Tdap/Td ± tetanus immunoglobulin.';
|
||||
html += '</div>';
|
||||
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Burn center referral (ABA)</h5>';
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;">Partial-thickness >10% TBSA; any full-thickness; face/hands/feet/genital/perineum/major joints; electrical/chemical/inhalation; associated trauma; significant comorbidities; pediatric burns in non-pediatric center.</div>';
|
||||
html += S.ref('ABA Advanced Burn Life Support (ABLS) 2018. Parkland formula: Baxter 1968. Lund-Browder 1944 chart (age-adjusted regions).');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-burn-calc' || e.target.closest('#btn-burn-calc')) calcBurn();
|
||||
if (e.target.id === 'btn-burn-reset' || e.target.closest('#btn-burn-reset')) resetParts();
|
||||
// Render body-parts table the first (or any) time the Burns sub-pill is activated
|
||||
var bp = e.target.closest && e.target.closest('[data-em="burns"]');
|
||||
if (bp) setTimeout(renderBodyParts, 0);
|
||||
// Also render if the top-level Bedside tab is clicked (ensures table ready before user navigates)
|
||||
var nav = e.target.closest && e.target.closest('[data-calc="bedside"]');
|
||||
if (nav) setTimeout(renderBodyParts, 50);
|
||||
});
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target && e.target.id === 'burn-age') renderBodyParts();
|
||||
});
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target && e.target.dataset && e.target.dataset.burnRegion) updateLive();
|
||||
});
|
||||
// Also attempt render on these events in case component mounts later
|
||||
document.addEventListener('DOMContentLoaded', renderBodyParts);
|
||||
document.addEventListener('tabChanged', function() { setTimeout(renderBodyParts, 100); });
|
||||
if (document.readyState !== 'loading') setTimeout(renderBodyParts, 0);
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/cardiac.js
|
||||
// CARDIAC ARREST / PALS.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function getWt() {
|
||||
return parseFloat(document.getElementById('cardiac-weight').value) || 0;
|
||||
}
|
||||
|
||||
function title(k) {
|
||||
return { general: 'PALS General Doses', asystole: 'Asystole / PEA', brady: 'Bradycardia', svt: 'SVT', vfib: 'VF / Pulseless VT', vt: 'Stable VT' }[k] || k;
|
||||
}
|
||||
|
||||
function cardiacShow(kind) {
|
||||
var wt = getWt();
|
||||
var el = document.getElementById('cardiac-result');
|
||||
if (!wt) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--red)10;border:1.5px solid var(--red);margin-bottom:10px;"><strong style="color:var(--red);">' + title(kind) + ' — ' + wt + ' kg</strong></div>';
|
||||
|
||||
if (kind === 'general') {
|
||||
html += S.drugTable(
|
||||
S.drugRow('Epinephrine 1:10,000', S.dStr(wt, 0.01, 1, ' mg') + ' (' + S.dStr(wt, 0.1, 10, ' mL') + ')', 'IV / IO', '0.01 mg/kg = 0.1 mL/kg of 1:10,000. Max single dose 1 mg. Repeat q3-5 min.') +
|
||||
S.drugRow('Epinephrine ET', S.dStr(wt, 0.1, 2.5, ' mg'), 'ETT', '0.1 mg/kg (1 mL/kg of 1:10,000) if no IV/IO') +
|
||||
S.drugRow('Amiodarone', S.dStr(wt, 5, 300, ' mg'), 'IV / IO bolus', '5 mg/kg for arrest. Max 300 mg. Repeat up to 2 times (max 15 mg/kg/day).') +
|
||||
S.drugRow('Lidocaine', S.dStr(wt, 1, 100, ' mg'), 'IV / IO bolus', '1 mg/kg. Alternative to amiodarone for VF/VT.') +
|
||||
S.drugRow('Atropine', S.dStr(wt, 0.02, 0.5, ' mg') + ' (min 0.1 mg)', 'IV / IO', '0.02 mg/kg. Bradycardia from ↑vagal tone or AV block.') +
|
||||
S.drugRow('Adenosine (1st)', S.dStr(wt, 0.1, 6, ' mg'), 'IV push + flush', '0.1 mg/kg (max 6 mg). For SVT. Rapid push, double flush.') +
|
||||
S.drugRow('Adenosine (2nd)', S.dStr(wt, 0.2, 12, ' mg'), 'IV push + flush', '0.2 mg/kg (max 12 mg). Second dose if first unsuccessful.') +
|
||||
S.drugRow('Sodium bicarb 8.4%', S.dStr(wt, 1, 50, ' mEq'), 'IV / IO', '1 mEq/kg. ONLY if severe metabolic acidosis, hyperK, or TCA OD. Not routine.') +
|
||||
S.drugRow('Calcium chloride 10%', S.dStr(wt, 20, 1000, ' mg') + ' (' + S.dStr(wt, 0.2, 10, ' mL') + ')', 'IV / IO (central preferred)', 'For hyperK, hypoCa, Mg OD, CCB OD. 20 mg/kg = 0.2 mL/kg.') +
|
||||
S.drugRow('Calcium gluconate 10%', S.dStr(wt, 60, 3000, ' mg') + ' (' + S.dStr(wt, 0.6, 30, ' mL') + ')', 'IV / IO (peripheral OK)', '60 mg/kg = 0.6 mL/kg. Preferred peripherally.') +
|
||||
S.drugRow('Magnesium sulfate', S.dStr(wt, 50, 2000, ' mg'), 'IV over 10-20 min', '25-50 mg/kg. Torsades, severe asthma. Max 2 g.') +
|
||||
S.drugRow('Dextrose 10%', S.dStr(wt, 2, null, ' mL') + ' (0.2 g/kg)', 'IV push', 'For documented hypoglycemia') +
|
||||
S.drugRow('Defibrillation', '2 J/kg → 4 J/kg → 10 J/kg (max 10 J/kg or adult dose)', 'Pad', 'For VF/pulseless VT. Resume CPR immediately after shock.') +
|
||||
S.drugRow('Cardioversion (sync)', '0.5-1 J/kg → 2 J/kg', 'Pad', 'For unstable SVT/VT. Sedate if possible.')
|
||||
);
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;margin-top:10px;"><strong>Concentrations:</strong> Epi 1:10,000 (0.1 mg/mL) for IV arrest. 1:1000 (1 mg/mL) for IM anaphylaxis. Atropine <0.1 mg can cause paradoxical bradycardia.</div>';
|
||||
} else if (kind === 'asystole') {
|
||||
html += S.drugTable(
|
||||
S.drugRow('CPR', '100-120/min, depth 1/3 AP', '—', '15:2 (2 rescuer) or 30:2 (single). Rotate every 2 min.') +
|
||||
S.drugRow('Epinephrine', S.dStr(wt, 0.01, 1, ' mg') + ' IV/IO', 'IV / IO', '0.01 mg/kg 1:10,000 = 0.1 mL/kg. Q3-5 min. Start early.') +
|
||||
S.drugRow('Epinephrine ETT', S.dStr(wt, 0.1, 2.5, ' mg'), 'ETT', '0.1 mg/kg if no IV access')
|
||||
);
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Reversible causes (H\'s & T\'s)</h5>';
|
||||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:6px;font-size:12px;">';
|
||||
html += '<div style="padding:8px;background:var(--g50);border-radius:6px;"><strong>H\'s</strong><br>Hypoxia, Hypovolemia, H+ (acidosis), Hypo/hyperK, Hypoglycemia, Hypothermia</div>';
|
||||
html += '<div style="padding:8px;background:var(--g50);border-radius:6px;"><strong>T\'s</strong><br>Tension PTX, Tamponade, Toxins, Thrombosis (MI/PE), Trauma</div>';
|
||||
html += '</div>';
|
||||
} else if (kind === 'brady') {
|
||||
html += S.stepBox('#f59e0b', 'Bradycardia with poor perfusion (HR < 60)', 'Start CPR if HR < 60 with poor perfusion despite oxygenation and ventilation.');
|
||||
html += S.arrow;
|
||||
html += S.drugTable(
|
||||
S.drugRow('Epinephrine', S.dStr(wt, 0.01, 1, ' mg'), 'IV / IO', 'First-line. Q3-5 min.') +
|
||||
S.drugRow('Atropine', S.dStr(wt, 0.02, 0.5, ' mg') + ' (min 0.1 mg)', 'IV / IO', 'If ↑ vagal tone or AV block. Max 1 mg child / 0.5 mg infant.') +
|
||||
S.drugRow('Transcutaneous pacing', '—', 'Pad', 'For refractory bradycardia not responsive to drugs.') +
|
||||
S.drugRow('Consider', '—', '—', 'Hypoxia, tension pneumothorax, ↑ICP, toxic ingestion (organo, CCB, BB), heart block')
|
||||
);
|
||||
} else if (kind === 'svt') {
|
||||
html += S.stepBox('#3b82f6', 'SVT — narrow complex, rate usually >220 infant / >180 child', 'Differentiate from sinus tach: abrupt onset/offset, no P waves or abnormal P axis, HR minimally variable.');
|
||||
html += S.arrow;
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:6px 0;">If STABLE</h5>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Vagal maneuvers', 'Ice to face, blow into syringe, Valsalva', '—', 'Try first in stable patient') +
|
||||
S.drugRow('Adenosine (1st)', S.dStr(wt, 0.1, 6, ' mg'), 'IV push (proximal) + flush', 'Rapid push, 3-way stopcock with NS flush') +
|
||||
S.drugRow('Adenosine (2nd)', S.dStr(wt, 0.2, 12, ' mg'), 'IV push + flush', 'If first dose unsuccessful') +
|
||||
S.drugRow('Procainamide', '15 mg/kg over 30-60 min', 'IV', 'Expert consult. Avoid with amiodarone.') +
|
||||
S.drugRow('Amiodarone', '5 mg/kg over 20-60 min', 'IV', 'Expert consult.')
|
||||
);
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:10px 0 4px;">If UNSTABLE</h5>';
|
||||
html += S.drugTable(S.drugRow('Synchronized cardioversion', '0.5-1 J/kg → 2 J/kg', 'Pad', 'Sedate if possible. Pre-treat with adenosine if IV available.'));
|
||||
} else if (kind === 'vfib') {
|
||||
html += S.stepBox('#dc2626', 'VF / Pulseless VT', 'High-quality CPR + defibrillation are key. Minimize interruptions.');
|
||||
html += S.arrow;
|
||||
html += S.drugTable(
|
||||
S.drugRow('Defibrillation (1st)', '2 J/kg', 'Pad', 'Resume CPR immediately after shock.') +
|
||||
S.drugRow('Defibrillation (2nd)', '4 J/kg', 'Pad', 'After 2 min CPR.') +
|
||||
S.drugRow('Defibrillation (3rd+)', '≥4 J/kg (max 10 J/kg or adult dose)', 'Pad', '') +
|
||||
S.drugRow('Epinephrine', S.dStr(wt, 0.01, 1, ' mg'), 'IV / IO', 'After 2nd shock. Q3-5 min.') +
|
||||
S.drugRow('Amiodarone', S.dStr(wt, 5, 300, ' mg'), 'IV / IO bolus', 'Refractory VF/VT. Max 15 mg/kg/day.') +
|
||||
S.drugRow('Lidocaine', S.dStr(wt, 1, 100, ' mg'), 'IV / IO', 'Alternative to amiodarone. Then 20-50 mcg/kg/min gtt.') +
|
||||
S.drugRow('Mg sulfate', S.dStr(wt, 50, 2000, ' mg'), 'IV over 1-2 min', 'For torsades or hypoMg.')
|
||||
);
|
||||
} else if (kind === 'vt') {
|
||||
html += S.stepBox('#f59e0b', 'Stable monomorphic VT', 'Expert consult. Avoid combining QT-prolonging agents.');
|
||||
html += S.arrow;
|
||||
html += S.drugTable(
|
||||
S.drugRow('Amiodarone', '5 mg/kg over 20-60 min', 'IV', '= ' + S.d(wt, 5, 300) + ' mg over 20-60 min') +
|
||||
S.drugRow('Procainamide', '15 mg/kg over 30-60 min', 'IV', 'Do not combine with amiodarone') +
|
||||
S.drugRow('Sync cardioversion', '0.5-1 → 2 J/kg', 'Pad', 'If becomes unstable. Sedate.')
|
||||
);
|
||||
}
|
||||
html += S.ref('AHA PALS Guidelines 2020. Always verify against institutional protocols.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('[data-cardiac]');
|
||||
if (btn) cardiacShow(btn.dataset.cardiac);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/image-lightbox.js
|
||||
// IMAGE LIGHTBOX — any element with data-img-src opens fullscreen.
|
||||
// ============================================================
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
var opener = e.target.closest && e.target.closest('[data-img-src]');
|
||||
if (opener) {
|
||||
e.preventDefault();
|
||||
var lb = document.getElementById('img-lightbox');
|
||||
if (!lb) return;
|
||||
var src = opener.dataset.imgSrc;
|
||||
var title = opener.dataset.imgTitle || '';
|
||||
document.getElementById('img-lightbox-img').src = src;
|
||||
document.getElementById('img-lightbox-img').alt = title;
|
||||
document.getElementById('img-lightbox-title').textContent = title;
|
||||
document.getElementById('img-lightbox-open').href = src;
|
||||
lb.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
// Close on close button, on backdrop click (not on image), and on Escape (below)
|
||||
var closeBtn = e.target.closest && e.target.closest('#img-lightbox-close');
|
||||
var lb2 = document.getElementById('img-lightbox');
|
||||
if (!lb2 || lb2.style.display !== 'flex') return;
|
||||
if (closeBtn || e.target === lb2) {
|
||||
lb2.style.display = 'none';
|
||||
document.getElementById('img-lightbox-img').src = '';
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
var lb = document.getElementById('img-lightbox');
|
||||
if (lb && lb.style.display === 'flex') {
|
||||
lb.style.display = 'none';
|
||||
document.getElementById('img-lightbox-img').src = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/index.js
|
||||
// Imports + registers all Bedside section modules on load.
|
||||
// Loaded as <script type="module"> from index.html and e2e-harness.html.
|
||||
// ============================================================
|
||||
|
||||
import './shared.js'; // side-effect: sets window._EM for back-compat
|
||||
import * as ageWeight from './age-weight.js';
|
||||
import * as subNav from './sub-nav.js';
|
||||
import * as lightbox from './image-lightbox.js';
|
||||
import * as neonatal from './neonatal.js';
|
||||
import * as airway from './airway.js';
|
||||
import * as cardiac from './cardiac.js';
|
||||
import * as respiratory from './respiratory.js';
|
||||
import * as ventilation from './ventilation.js';
|
||||
import * as seizure from './seizure.js';
|
||||
import * as sepsis from './sepsis.js';
|
||||
import * as anaphylaxis from './anaphylaxis.js';
|
||||
import * as sedation from './sedation.js';
|
||||
import * as agitation from './agitation.js';
|
||||
import * as antiemetics from './antiemetics.js';
|
||||
import * as antimicrobials from './antimicrobials.js';
|
||||
import * as burns from './burns.js';
|
||||
import * as toxicology from './toxicology.js';
|
||||
import * as trauma from './trauma.js';
|
||||
|
||||
[
|
||||
ageWeight,
|
||||
subNav,
|
||||
lightbox,
|
||||
neonatal,
|
||||
airway,
|
||||
cardiac,
|
||||
respiratory,
|
||||
ventilation,
|
||||
seizure,
|
||||
sepsis,
|
||||
anaphylaxis,
|
||||
sedation,
|
||||
agitation,
|
||||
antiemetics,
|
||||
antimicrobials,
|
||||
burns,
|
||||
toxicology,
|
||||
trauma,
|
||||
].forEach(function(m) { if (m && typeof m.init === 'function') m.init(); });
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/neonatal.js
|
||||
// NEONATAL ASSESSMENT (Fenton growth) + NRP pathway + Apgar.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
// Fenton 2013 LMS data (weight in grams, GA in weeks).
|
||||
//
|
||||
// LMS parameters derived empirically from peditools.org/fenton2013 —
|
||||
// peditools is widely used and consistent with the published
|
||||
// Fenton TR, Kim JH. BMC Pediatrics 2013;13:59 reference. Each week's
|
||||
// triple was fit against 6 probe weights per week (RMSE < 0.005 z-score
|
||||
// units). Replaces an earlier hand-rounded table whose z-scores drifted
|
||||
// ~0.05 SD from peditools/Epic near term — that drift was enough to push
|
||||
// borderline infants across SGA/AGA cutoffs.
|
||||
//
|
||||
// Validated test cases:
|
||||
// 40 5/7 wk male, 3070g → z = -1.42 (Epic: -1.43, peditools: -1.42)
|
||||
var fentonLMS = {
|
||||
male: {
|
||||
22:{L:0.5885,M:496,S:0.12802},23:{L:0.7565,M:571,S:0.14547},24:{L:0.9128,M:651,S:0.16235},25:{L:1.0544,M:741,S:0.17765},
|
||||
26:{L:1.1862,M:841,S:0.19029},27:{L:1.3051,M:953,S:0.19989},28:{L:1.3699,M:1079,S:0.20777},29:{L:1.4165,M:1223,S:0.21163},
|
||||
30:{L:1.4172,M:1388,S:0.21185},31:{L:1.3755,M:1578,S:0.20785},32:{L:1.2952,M:1790,S:0.20112},33:{L:1.1974,M:2018,S:0.19143},
|
||||
34:{L:1.0743,M:2255,S:0.18119},35:{L:0.9583,M:2493,S:0.16992},36:{L:0.8460,M:2726,S:0.16001},37:{L:0.7543,M:2947,S:0.15072},
|
||||
38:{L:0.6650,M:3156,S:0.14304},39:{L:0.5881,M:3360,S:0.13641},40:{L:0.5237,M:3568,S:0.13173},41:{L:0.4691,M:3785,S:0.12863},
|
||||
42:{L:0.4216,M:4014,S:0.12735}
|
||||
},
|
||||
female: {
|
||||
22:{L:-0.0868,M:481,S:0.13605},23:{L:0.2119,M:537,S:0.14635},24:{L:0.5281,M:606,S:0.16134},25:{L:0.8258,M:694,S:0.18077},
|
||||
26:{L:1.0501,M:792,S:0.19889},27:{L:1.2084,M:899,S:0.21323},28:{L:1.2599,M:1017,S:0.22437},29:{L:1.2539,M:1152,S:0.22982},
|
||||
30:{L:1.2262,M:1306,S:0.23082},31:{L:1.1223,M:1482,S:0.22733},32:{L:1.0122,M:1681,S:0.21846},33:{L:0.8746,M:1897,S:0.20681},
|
||||
34:{L:0.7299,M:2126,S:0.19407},35:{L:0.5929,M:2362,S:0.18059},36:{L:0.4534,M:2602,S:0.17028},37:{L:0.3462,M:2835,S:0.16139},
|
||||
38:{L:0.2636,M:3050,S:0.15513},39:{L:0.2069,M:3239,S:0.15004},40:{L:0.1670,M:3415,S:0.14649},41:{L:0.1517,M:3596,S:0.14359},
|
||||
42:{L:0.1308,M:3787,S:0.14127}
|
||||
}
|
||||
};
|
||||
|
||||
function interpolateLMS(table, val) {
|
||||
var keys = Object.keys(table).map(Number).sort(function(a,b){return a-b;});
|
||||
if (val <= keys[0]) return table[keys[0]];
|
||||
if (val >= keys[keys.length-1]) return table[keys[keys.length-1]];
|
||||
for (var i = 0; i < keys.length - 1; i++) {
|
||||
if (val >= keys[i] && val <= keys[i+1]) {
|
||||
var t = (val - keys[i]) / (keys[i+1] - keys[i]);
|
||||
var a = table[keys[i]], b = table[keys[i+1]];
|
||||
return { L: a.L + t*(b.L-a.L), M: a.M + t*(b.M-a.M), S: a.S + t*(b.S-a.S) };
|
||||
}
|
||||
}
|
||||
return table[keys[0]];
|
||||
}
|
||||
|
||||
function calcZ(value, L, M, S) {
|
||||
if (Math.abs(L) < 0.001) return Math.log(value / M) / S;
|
||||
return (Math.pow(value / M, L) - 1) / (L * S);
|
||||
}
|
||||
|
||||
function zToPercentile(z) {
|
||||
// Approximation of the standard normal CDF
|
||||
var a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911;
|
||||
var sign = z < 0 ? -1 : 1;
|
||||
var x = Math.abs(z) / Math.sqrt(2);
|
||||
var t = 1 / (1 + p * x);
|
||||
var y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-x*x);
|
||||
return Math.round(((1 + sign * y) / 2) * 1000) / 10;
|
||||
}
|
||||
|
||||
function classifyGA(weeks, days) {
|
||||
var total = weeks + (days || 0) / 7;
|
||||
if (total < 28) return { label: 'Extremely Preterm', color: '#dc2626', icon: 'fa-triangle-exclamation' };
|
||||
if (total < 32) return { label: 'Very Preterm', color: '#ea580c', icon: 'fa-triangle-exclamation' };
|
||||
if (total < 34) return { label: 'Moderate Preterm', color: '#d97706', icon: 'fa-circle-exclamation' };
|
||||
if (total < 37) return { label: 'Late Preterm', color: '#ca8a04', icon: 'fa-circle-info' };
|
||||
if (total < 39) return { label: 'Early Term', color: '#2563eb', icon: 'fa-circle-info' };
|
||||
if (total < 41) return { label: 'Full Term', color: '#16a34a', icon: 'fa-circle-check' };
|
||||
if (total < 42) return { label: 'Late Term', color: '#d97706', icon: 'fa-circle-info' };
|
||||
return { label: 'Post Term', color: '#dc2626', icon: 'fa-triangle-exclamation' };
|
||||
}
|
||||
|
||||
function classifyWeight(percentile) {
|
||||
if (percentile < 3) return { label: 'Severely SGA', color: '#dc2626', detail: '<3rd percentile' };
|
||||
if (percentile < 10) return { label: 'SGA', color: '#ea580c', detail: '<10th percentile' };
|
||||
if (percentile > 97) return { label: 'Severely LGA', color: '#dc2626', detail: '>97th percentile' };
|
||||
if (percentile > 90) return { label: 'LGA', color: '#ea580c', detail: '>90th percentile' };
|
||||
return { label: 'AGA', color: '#16a34a', detail: '10th-90th percentile' };
|
||||
}
|
||||
|
||||
function classifyBirthWeight(grams) {
|
||||
if (grams < 1000) return { label: 'Extremely Low Birth Weight (ELBW)', color: '#dc2626' };
|
||||
if (grams < 1500) return { label: 'Very Low Birth Weight (VLBW)', color: '#ea580c' };
|
||||
if (grams < 2500) return { label: 'Low Birth Weight (LBW)', color: '#d97706' };
|
||||
if (grams <= 4000) return { label: 'Normal Birth Weight', color: '#16a34a' };
|
||||
return { label: 'Macrosomia (>4000g)', color: '#ea580c' };
|
||||
}
|
||||
|
||||
function neoAssess() {
|
||||
var weeks = parseInt(document.getElementById('neo-ga-weeks').value);
|
||||
var days = parseInt(document.getElementById('neo-ga-days').value) || 0;
|
||||
var weight = parseFloat(document.getElementById('neo-weight').value);
|
||||
var sex = document.getElementById('neo-sex').value;
|
||||
var resultDiv = document.getElementById('neo-result');
|
||||
|
||||
if (!weeks || !weight) {
|
||||
resultDiv.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter GA and birth weight.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var gaDecimal = weeks + days / 7;
|
||||
var gaClass = classifyGA(weeks, days);
|
||||
var bwClass = classifyBirthWeight(weight);
|
||||
|
||||
// Calculate percentile using Fenton LMS
|
||||
var lms = interpolateLMS(fentonLMS[sex], gaDecimal);
|
||||
var z = calcZ(weight, lms.L, lms.M, lms.S);
|
||||
var percentile = zToPercentile(z);
|
||||
var wtClass = classifyWeight(percentile);
|
||||
var expectedWeight = Math.round(lms.M);
|
||||
|
||||
var html = '';
|
||||
|
||||
// GA Classification
|
||||
html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">';
|
||||
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + gaClass.color + '10;border:1.5px solid ' + gaClass.color + '30;">';
|
||||
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Gestational Age Classification</div>';
|
||||
html += '<div style="font-size:18px;font-weight:700;color:' + gaClass.color + ';"><i class="fas ' + gaClass.icon + '"></i> ' + gaClass.label + '</div>';
|
||||
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weeks + ' weeks ' + days + ' days (' + gaDecimal.toFixed(1) + ' weeks)</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Weight Classification (AGA/SGA/LGA)
|
||||
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + wtClass.color + '10;border:1.5px solid ' + wtClass.color + '30;">';
|
||||
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Weight for Gestational Age</div>';
|
||||
html += '<div style="font-size:18px;font-weight:700;color:' + wtClass.color + ';">' + wtClass.label + '</div>';
|
||||
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + percentile.toFixed(1) + 'th percentile (' + wtClass.detail + ')</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Birth Weight Category
|
||||
html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">';
|
||||
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + bwClass.color + '10;border:1.5px solid ' + bwClass.color + '30;">';
|
||||
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Birth Weight Category</div>';
|
||||
html += '<div style="font-size:18px;font-weight:700;color:' + bwClass.color + ';">' + bwClass.label + '</div>';
|
||||
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weight + 'g (' + (weight/1000).toFixed(2) + ' kg)</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Stats
|
||||
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:var(--g50);border:1.5px solid var(--g200);">';
|
||||
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Fenton Growth Data (' + (sex === 'male' ? 'Male' : 'Female') + ')</div>';
|
||||
html += '<div style="font-size:13px;color:var(--g700);line-height:1.8;">';
|
||||
html += '<strong>Expected weight (50th %ile):</strong> ' + expectedWeight + 'g<br>';
|
||||
html += '<strong>Z-score:</strong> ' + z.toFixed(2) + '<br>';
|
||||
html += '<strong>Percentile:</strong> ' + percentile.toFixed(1) + '%';
|
||||
html += '</div></div>';
|
||||
html += '</div>';
|
||||
|
||||
// Reference
|
||||
html += '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">Fenton TR, Kim JH. A systematic review and meta-analysis to revise the Fenton growth chart for preterm infants. BMC Pediatrics 2013;13:59. GA classification per ACOG/AAP definitions.</p>';
|
||||
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
// ---- NRP pathway + drug calc -----------------------------------------
|
||||
|
||||
function renderPathway() {
|
||||
var el = document.getElementById('nrp-pathway-static');
|
||||
if (!el || el.dataset.rendered) return;
|
||||
var html = '';
|
||||
html += S.stepBox('#3b82f6', 'BIRTH — ASSESS (first 30 sec)', 'Term? Tone? Breathing/crying? If <strong>all yes</strong> → routine care with mother. If <strong>any no</strong> → warm, dry, stimulate, position airway, clear airway PRN, evaluate HR & respirations.');
|
||||
html += S.arrow;
|
||||
html += S.stepBox('#8b5cf6', 'HR < 100 OR apneic/gasping (60 sec)', '<strong>Start PPV</strong> 40-60 breaths/min, room air for term / 21-30% for preterm. Attach SpO2 (right hand) ± ECG. MR SOPA if ineffective: <em>M</em>ask adjust, <em>R</em>eposition, <em>S</em>uction, <em>O</em>pen mouth, <em>P</em>ressure increase, <em>A</em>lternative airway.');
|
||||
html += S.arrow;
|
||||
html += S.stepBox('#f59e0b', 'HR < 100 after 30 sec effective PPV', 'Reassess ventilation — ensure chest rise. Consider increasing FiO2, intubation, or LMA. Continue PPV.');
|
||||
html += S.arrow;
|
||||
html += S.stepBox('#ef4444', 'HR < 60 after 30 sec of effective PPV', '<strong>Intubate + Chest compressions</strong> — 3:1 ratio (90 compressions + 30 breaths per minute), FiO2 100%, lower 1/3 sternum, depth 1/3 AP chest.');
|
||||
html += S.arrow;
|
||||
html += S.stepBox('#dc2626', 'HR < 60 despite compressions + PPV x 60 sec', '<strong>Epinephrine</strong> 1:10,000 (0.1 mg/mL): <br>• <strong>IV/IO:</strong> 0.01-0.03 mg/kg (0.1-0.3 mL/kg) — preferred<br>• <strong>ETT:</strong> 0.05-0.1 mg/kg (0.5-1 mL/kg) — while IV being placed<br>Repeat q3-5 min. If hypovolemia: <strong>NS 10 mL/kg IV/IO</strong> over 5-10 min.');
|
||||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-top:14px;">';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>Target SpO2 (preductal):</strong><br>1 min: 60-65%<br>2 min: 65-70%<br>3 min: 70-75%<br>4 min: 75-80%<br>5 min: 80-85%<br>10 min: 85-95%</div>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>Initial ETT size:</strong><br><1 kg / <28 wk: <strong>2.5</strong><br>1-2 kg / 28-34 wk: <strong>3.0</strong><br>2-3 kg / 34-38 wk: <strong>3.5</strong><br>>3 kg / >38 wk: <strong>3.5-4.0</strong></div>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>ETT depth (lip):</strong><br>~ 6 + weight(kg) cm</div>';
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
el.dataset.rendered = '1';
|
||||
}
|
||||
|
||||
function calcNRP() {
|
||||
var wt = parseFloat(document.getElementById('nrp-weight').value);
|
||||
var el = document.getElementById('nrp-result');
|
||||
if (!wt) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
var epiIvLow = Math.round(wt * 0.01 * 100) / 100;
|
||||
var epiIvHigh = Math.round(wt * 0.03 * 100) / 100;
|
||||
var epiEtLow = Math.round(wt * 0.05 * 100) / 100;
|
||||
var epiEtHigh = Math.round(wt * 0.1 * 100) / 100;
|
||||
var ns = Math.round(wt * 10);
|
||||
var d10 = Math.round(wt * 2 * 10) / 10;
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--red)10;border:1.5px solid var(--red);margin-bottom:10px;"><strong style="color:var(--red);">NRP doses — ' + wt + ' kg</strong></div>';
|
||||
var pk = function(txt) { return ' <span style="color:var(--g500);font-size:11px;">(' + txt + ')</span>'; };
|
||||
html += S.drugTable(
|
||||
S.drugRow('Epinephrine 1:10,000', epiIvLow + '-' + epiIvHigh + ' mg, ' + (Math.round(epiIvLow*10)/10) + '-' + (Math.round(epiIvHigh*10)/10) + ' mL' + pk('0.01-0.03 mg/kg = 0.1-0.3 mL/kg'), 'IV / IO', 'Preferred route. Repeat q3-5 min.') +
|
||||
S.drugRow('Epinephrine 1:10,000', epiEtLow + '-' + epiEtHigh + ' mg, ' + (Math.round(epiEtLow*10)/10) + '-' + (Math.round(epiEtHigh*10)/10) + ' mL' + pk('0.05-0.1 mg/kg = 0.5-1 mL/kg'), 'ETT', 'While IV being placed.') +
|
||||
S.drugRow('Normal saline', ns + ' mL' + pk('10 mL/kg'), 'IV / IO', 'Over 5-10 min for volume. Repeat PRN.') +
|
||||
S.drugRow('Dextrose 10%', d10 + ' mL' + pk('2 mL/kg = 0.2 g/kg'), 'IV slow push', 'For documented hypoglycemia. Then D10 infusion 4-6 mg/kg/min.')
|
||||
);
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;"><strong>Concentration note:</strong> NRP uses epinephrine <strong>1:10,000</strong> (0.1 mg/mL). NOT 1:1000 (1 mg/mL) — that is IM for anaphylaxis / older patients.</div>';
|
||||
html += S.ref('AHA/AAP Neonatal Resuscitation Program (NRP) 8th edition, 2020.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ---- Apgar -----------------------------------------------------------
|
||||
|
||||
function calcApgar() {
|
||||
var keys = ['appearance','pulse','grimace','activity','respiration'];
|
||||
var total = keys.reduce(function(s, k) {
|
||||
return s + (parseInt(document.getElementById('apgar-' + k).value) || 0);
|
||||
}, 0);
|
||||
var el = document.getElementById('apgar-result');
|
||||
var color, severity, guidance;
|
||||
if (total >= 7) {
|
||||
color = '#10b981'; severity = 'Reassuring';
|
||||
guidance = 'Routine newborn care. Continue reassessment. Repeat at 5 min.';
|
||||
} else if (total >= 4) {
|
||||
color = '#f59e0b'; severity = 'Moderately depressed';
|
||||
guidance = 'Stimulate, clear airway, warm. Give O2 if cyanotic. Ventilate with PPV if HR <100 or apneic/gasping. Reassess q30 sec.';
|
||||
} else {
|
||||
color = '#ef4444'; severity = 'Severely depressed';
|
||||
guidance = 'Full NRP pathway — PPV immediately. Intubate if PPV ineffective. Chest compressions if HR <60. Epinephrine and volume per NRP.';
|
||||
}
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:' + color + '10;border:1.5px solid ' + color + ';">';
|
||||
html += '<strong style="color:' + color + ';font-size:16px;">Apgar: ' + total + '/10 — ' + severity + '</strong>';
|
||||
html += '<div style="font-size:12px;color:var(--g700);margin-top:6px;line-height:1.5;">' + guidance + '</div></div>';
|
||||
html += '<p style="font-size:11px;color:var(--g400);margin-top:8px;font-style:italic;">Apgar is a description of status — <strong>never</strong> delay resuscitation while scoring. Follow NRP algorithm based on HR and breathing. Apgar <7 at 5 min: repeat q5 min up to 20 min.</p>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
// Neonatal assessment
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-neo-assess' || e.target.closest('#btn-neo-assess')) neoAssess();
|
||||
});
|
||||
|
||||
// NRP pathway render + calc
|
||||
document.addEventListener('DOMContentLoaded', renderPathway);
|
||||
document.addEventListener('tabChanged', function() { setTimeout(renderPathway, 100); });
|
||||
if (document.readyState !== 'loading') setTimeout(renderPathway, 0);
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-nrp-calc' || e.target.closest('#btn-nrp-calc')) calcNRP();
|
||||
// Render NRP pathway when Neonatal sub-pill is clicked (in case component loaded late)
|
||||
var np = e.target.closest && e.target.closest('[data-em="neonatal"]');
|
||||
if (np) setTimeout(renderPathway, 0);
|
||||
var nav = e.target.closest && e.target.closest('[data-calc="bedside"]');
|
||||
if (nav) setTimeout(renderPathway, 50);
|
||||
});
|
||||
|
||||
// Apgar
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-apgar-calc' || e.target.closest('#btn-apgar-calc')) calcApgar();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/respiratory.js
|
||||
// RESPIRATORY MANAGEMENT (Asthma, PRAM, Croup, Bronchiolitis).
|
||||
// Keeps its own local dose/doseStr/drugRow/drugTable helpers —
|
||||
// the layout differs from S.drugTable (no 'Notes' column alignment).
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function dose(wt, mgPerKg, maxMg) { var d = Math.round(wt * mgPerKg * 10) / 10; return maxMg ? Math.min(d, maxMg) : d; }
|
||||
// Delegates to the shared S.dStr so the per-kg math is visible to the prescriber.
|
||||
function doseStr(wt, mgPerKg, maxMg, unit) {
|
||||
return S.dStr(wt, mgPerKg, maxMg, unit);
|
||||
}
|
||||
function drugRow(name, doseText, route, notes) {
|
||||
return '<tr><td style="font-weight:600;">' + name + '</td><td>' + doseText + '</td><td>' + route + '</td><td style="font-size:12px;color:var(--g500);">' + (notes || '') + '</td></tr>';
|
||||
}
|
||||
function drugTable(rows) {
|
||||
return '<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;margin:8px 0;"><table style="width:100%;min-width:500px;border-collapse:collapse;font-size:13px;"><thead><tr style="background:var(--g100);"><th style="text-align:left;padding:6px 8px;">Drug</th><th style="padding:6px 8px;">Dose</th><th style="padding:6px 8px;">Route</th><th style="padding:6px 8px;">Notes</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||||
}
|
||||
function severityBadge(label, color) { return '<span style="display:inline-block;padding:4px 12px;border-radius:6px;background:' + color + '20;color:' + color + ';font-weight:700;font-size:14px;">' + label + '</span>'; }
|
||||
|
||||
function asthmaManagement(severity) {
|
||||
var wt = parseFloat(document.getElementById('resp-weight').value);
|
||||
var resultDiv = document.getElementById('asthma-result');
|
||||
if (!wt) { resultDiv.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
|
||||
var html = '';
|
||||
if (severity === 'mild') {
|
||||
html += severityBadge('Mild Exacerbation', '#10b981');
|
||||
html += '<p style="font-size:13px;margin:8px 0;">Speaks in sentences, no accessory muscle use, SpO2 ≥94%</p>';
|
||||
html += drugTable(
|
||||
drugRow('Albuterol (MDI)', '4-8 puffs via spacer', 'Inhaled', 'q20min x 3 doses, then q1-4h') +
|
||||
drugRow('Albuterol (neb)', doseStr(wt, 0.15, 5, ' mg') + ' (min 2.5 mg)', 'Nebulized', 'q20min x 3 doses') +
|
||||
drugRow('Dexamethasone', doseStr(wt, 0.6, 16), 'PO/IV', 'Single dose, or 2 days') +
|
||||
drugRow('Prednisolone', doseStr(wt, 1, 60) + '/day', 'PO', 'Alternative: 3-5 day course')
|
||||
);
|
||||
html += '<div style="margin-top:8px;padding:8px 12px;background:#d1fae5;border-radius:6px;font-size:12px;color:#065f46;"><strong>Reassess after 1 hour.</strong> If improving → discharge with albuterol MDI + spacer + oral steroid course. If not improving → escalate to moderate.</div>';
|
||||
} else if (severity === 'moderate') {
|
||||
html += severityBadge('Moderate Exacerbation', '#f59e0b');
|
||||
html += '<p style="font-size:13px;margin:8px 0;">Speaks in phrases, some accessory muscle use, SpO2 90-93%</p>';
|
||||
html += drugTable(
|
||||
drugRow('Albuterol (neb)', doseStr(wt, 0.15, 5, ' mg') + ' (min 2.5 mg)', 'Nebulized', 'q20min x 3 doses, then continuous if needed') +
|
||||
drugRow('Ipratropium', wt < 20 ? '250 mcg' : '500 mcg', 'Nebulized', 'q20min x 3 doses with albuterol') +
|
||||
drugRow('Dexamethasone', doseStr(wt, 0.6, 16), 'PO/IV/IM', 'Single dose') +
|
||||
drugRow('O2 supplemental', 'Target SpO2 ≥94%', 'Nasal cannula/mask', 'Titrate to effect')
|
||||
);
|
||||
html += '<div style="margin-top:8px;padding:8px 12px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;"><strong>Reassess after 1-2 hours.</strong> If improving → step down to mild protocol. If worsening or no improvement → escalate to severe.</div>';
|
||||
} else {
|
||||
html += severityBadge('Severe / Life-Threatening', '#ef4444');
|
||||
html += '<p style="font-size:13px;margin:8px 0;">Speaks in words only, significant accessory muscle use, SpO2 <90%. Consider ICU.</p>';
|
||||
html += drugTable(
|
||||
drugRow('Albuterol continuous', doseStr(wt, 0.5, 20, ' mg') + '/hr', 'Continuous neb', 'Or 0.15-0.3 mg/kg q20min') +
|
||||
drugRow('Ipratropium', wt < 20 ? '250 mcg' : '500 mcg', 'Nebulized', 'q20min x 3 doses with albuterol') +
|
||||
drugRow('Dexamethasone', doseStr(wt, 0.6, 16), 'IV', 'Or methylprednisolone 2 mg/kg IV (max 60 mg)') +
|
||||
drugRow('Magnesium sulfate', doseStr(wt, 50, 2000) + ' IV over 20 min', 'IV', 'Single dose, monitor BP') +
|
||||
drugRow('Epinephrine (IM)', doseStr(wt, 0.01, 0.5) + ' (1:1000)', 'IM', 'If impending arrest / no IV access') +
|
||||
drugRow('Terbutaline', doseStr(wt, 0.01, 0.4) + ' SC/IV', 'SC or IV bolus', 'Then 0.1-10 mcg/kg/min infusion') +
|
||||
drugRow('O2 supplemental', 'Target SpO2 ≥94%', 'High flow / NIPPV', 'Consider BiPAP/CPAP')
|
||||
);
|
||||
html += '<div style="margin-top:8px;padding:8px 12px;background:#fee2e2;border-radius:6px;font-size:12px;color:#991b1b;"><strong>Continuous monitoring.</strong> Consider ICU admission. If no response to magnesium → terbutaline infusion. If impending respiratory failure → intubation (ketamine preferred induction agent).</div>';
|
||||
|
||||
// Clinical decision points specific to severe asthma
|
||||
html += '<div style="margin-top:10px;display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:8px;">';
|
||||
|
||||
// ABG
|
||||
html += '<div style="padding:10px 12px;background:white;border:1px solid var(--g200);border-radius:8px;font-size:12px;line-height:1.5;">' +
|
||||
'<div style="font-weight:700;color:var(--blue-dark);margin-bottom:4px;"><i class="fas fa-flask"></i> When to obtain a blood gas</div>' +
|
||||
'• Severe exacerbation <strong>not responding</strong> after 1-2 h aggressive therapy<br>' +
|
||||
'• Impending respiratory failure — altered mentation, fatigue, silent chest<br>' +
|
||||
'• SpO2 <92% despite supplemental O2<br>' +
|
||||
'• Suspected CO2 retention (rising PCO2 in a previously tachypneic patient)<br>' +
|
||||
'<strong style="color:#991b1b;">Interpretation:</strong> asthmatics hyperventilate → expect <em>low</em> PCO2. A <strong>"normal" or rising PCO2 = impending failure</strong>. Don\'t delay treatment for the gas.' +
|
||||
'</div>';
|
||||
|
||||
// Intubation
|
||||
html += '<div style="padding:10px 12px;background:white;border:1px solid var(--g200);border-radius:8px;font-size:12px;line-height:1.5;">' +
|
||||
'<div style="font-weight:700;color:#dc2626;margin-bottom:4px;"><i class="fas fa-stethoscope"></i> When to intubate (mostly clinical)</div>' +
|
||||
'• <strong>Absolute:</strong> apnea, cardiac arrest, coma, inability to protect airway<br>' +
|
||||
'• Progressive fatigue despite maximal therapy (NIV, mag, terbutaline, heliox)<br>' +
|
||||
'• Refractory hypoxemia / hypercapnia with acidosis<br>' +
|
||||
'• Silent chest + deteriorating consciousness<br>' +
|
||||
'<strong>RSI choice:</strong> <span style="color:#065f46;font-weight:600;">ketamine 1-2 mg/kg</span> (bronchodilator, preserves BP) + <span style="color:#065f46;font-weight:600;">rocuronium</span>. Avoid succs if hyperkalemic. Permissive hypercapnia after tube; allow expiration (low rate, I:E 1:3-4).' +
|
||||
'</div>';
|
||||
|
||||
// Heliox
|
||||
html += '<div style="padding:10px 12px;background:white;border:1px solid var(--g200);border-radius:8px;font-size:12px;line-height:1.5;">' +
|
||||
'<div style="font-weight:700;color:#7c3aed;margin-bottom:4px;"><i class="fas fa-wind"></i> Heliox (He/O2)</div>' +
|
||||
'• Lower gas density → less turbulence → reduced work of breathing through narrowed airways.<br>' +
|
||||
'• <strong>Consider in:</strong> severe asthma not responding to max therapy (bridge), upper airway obstruction (croup, post-extubation stridor, FB partial).<br>' +
|
||||
'• Typical mix: <strong>70:30 He:O2</strong> (or 80:20). Delivered via tight non-rebreather or in-line with neb.<br>' +
|
||||
'• <strong>Limitation:</strong> requires FiO2 ≤ 30-40%. If patient needs higher FiO2, heliox can\'t help.<br>' +
|
||||
'• Evidence in asthma is mixed — use as adjunct, not substitute.' +
|
||||
'</div>';
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<p style="font-size:11px;color:var(--g400);margin:12px 0 0;">NAEPP/GINA guidelines. Always use clinical judgment. Verify doses against institutional protocols.</p>';
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function calcPRAM() {
|
||||
var total = ['pram-spo2','pram-retractions','pram-scalene','pram-air','pram-wheeze'].reduce(function(s, id) {
|
||||
return s + parseInt(document.getElementById(id).value);
|
||||
}, 0);
|
||||
var severity = total <= 3 ? 'Mild' : total <= 7 ? 'Moderate' : 'Severe';
|
||||
var color = total <= 3 ? '#10b981' : total <= 7 ? '#f59e0b' : '#ef4444';
|
||||
document.getElementById('pram-result').innerHTML = '<div style="padding:10px 14px;border-radius:8px;background:' + color + '10;border:1.5px solid ' + color + '30;"><span style="font-size:18px;font-weight:700;color:' + color + ';">PRAM Score: ' + total + '/12 — ' + severity + '</span><p style="font-size:12px;color:var(--g600);margin:4px 0 0;">Mild (0-3): outpatient management. Moderate (4-7): consider oral steroids + frequent bronchodilators. Severe (8-12): aggressive treatment, consider ICU.</p></div>';
|
||||
}
|
||||
|
||||
function calcCroup() {
|
||||
var wt = parseFloat(document.getElementById('resp-weight').value) || 10;
|
||||
var total = ['croup-conscious','croup-cyanosis','croup-stridor','croup-air','croup-retractions'].reduce(function(s, id) {
|
||||
return s + parseInt(document.getElementById(id).value);
|
||||
}, 0);
|
||||
var severity, color, treatment;
|
||||
if (total <= 2) { severity = 'Mild'; color = '#10b981'; treatment = drugTable(drugRow('Dexamethasone', doseStr(wt, 0.6, 16), 'PO (single dose)', 'Preferred corticosteroid') + drugRow('Supportive care', 'Cool mist, comfort measures', '', 'Discharge if tolerating PO')); }
|
||||
else if (total <= 5) { severity = 'Moderate'; color = '#f59e0b'; treatment = drugTable(drugRow('Dexamethasone', doseStr(wt, 0.6, 16), 'PO/IM', 'Single dose') + drugRow('Racemic epinephrine', '0.5 mL of 2.25% solution', 'Nebulized', 'Observe 2-4 hrs after for rebound') + drugRow('Nebulized epinephrine', '0.5 mL/kg of 1:1000 (max 5 mL)', 'Nebulized', 'Alternative to racemic')); }
|
||||
else if (total <= 11) { severity = 'Severe'; color = '#ef4444'; treatment = drugTable(drugRow('Dexamethasone', doseStr(wt, 0.6, 16), 'IV/IM', 'Immediate') + drugRow('Racemic epinephrine', '0.5 mL of 2.25% solution', 'Nebulized', 'May repeat q15-20min, observe 2-4 hrs') + drugRow('Nebulized epinephrine', '0.5 mL/kg of 1:1000 (max 5 mL)', 'Nebulized', 'Alternative') + drugRow('Heliox', '70:30 or 80:20', 'Face mask', 'Consider if not responding') + drugRow('O2 supplemental', 'Target SpO2 ≥94%', 'Blow-by preferred', 'Minimize agitation')); }
|
||||
else { severity = 'Impending Respiratory Failure'; color = '#dc2626'; treatment = '<div style="padding:10px;background:#fee2e2;border-radius:6px;font-size:13px;color:#991b1b;font-weight:600;">Immediate airway management. Prepare for intubation (use ETT 0.5-1 size smaller than predicted). Call anesthesia/ENT. Continue nebulized epinephrine and dexamethasone IV.</div>'; }
|
||||
|
||||
document.getElementById('croup-result').innerHTML = '<div style="padding:10px 14px;border-radius:8px;background:' + color + '10;border:1.5px solid ' + color + '30;margin-bottom:12px;"><span style="font-size:18px;font-weight:700;color:' + color + ';">Westley Score: ' + total + '/17 — ' + severity + '</span></div>' + treatment + '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">Westley WJ et al. Nebulized racemic epinephrine by IPPB for the treatment of croup. Am J Dis Child 1978. Mild ≤2, Moderate 3-5, Severe 6-11, Impending failure ≥12.</p>';
|
||||
}
|
||||
|
||||
function calcBronchiolitis() {
|
||||
var wt = parseFloat(document.getElementById('resp-weight').value) || 5;
|
||||
var age = document.getElementById('bronch-age').value;
|
||||
var spo2 = document.getElementById('bronch-spo2').value;
|
||||
var hydration = document.getElementById('bronch-hydration').value;
|
||||
var distress = document.getElementById('bronch-distress').value;
|
||||
|
||||
var html = '<h4 style="font-size:15px;font-weight:700;color:var(--g800);margin:0 0 8px;">Bronchiolitis Management</h4>';
|
||||
var admit = distress === 'severe' || spo2 === 'low' || hydration === 'poor' || age === '<12w';
|
||||
|
||||
if (admit) {
|
||||
html += severityBadge('Admit / Observe', '#ef4444');
|
||||
html += '<div style="margin:10px 0;">';
|
||||
if (age === '<12w') html += '<p style="font-size:13px;color:var(--red);font-weight:600;">⚠ Age <12 weeks — high risk for apnea. Monitor closely.</p>';
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += severityBadge('Likely Safe for Discharge', '#10b981');
|
||||
}
|
||||
|
||||
html += '<div style="margin:12px 0;"><strong style="font-size:13px;">Supportive Care (evidence-based):</strong></div>';
|
||||
html += drugTable(
|
||||
drugRow('Nasal suctioning', 'Bulb suction or NasalClear', 'Nasal', 'Before feeds and as needed') +
|
||||
drugRow('O2 supplemental', 'Target SpO2 ≥90%', 'NC / high flow', spo2 === 'low' ? 'Required' : 'If needed') +
|
||||
drugRow('Hypertonic saline 3%', '4 mL nebulized', 'Nebulized', 'Consider in inpatients (AAP weak recommendation)') +
|
||||
(hydration === 'poor' ? drugRow('IV fluids', 'NS/LR bolus ' + doseStr(wt, 20, null, ' mL'), 'IV', 'Then maintenance D5 0.45NS') + drugRow('NG feeds', 'If unable to feed orally', 'NG tube', 'Preferred over IV if gut functional') : '')
|
||||
);
|
||||
|
||||
html += '<div style="margin:12px 0;padding:10px 12px;background:var(--amber-light);border-radius:6px;font-size:12px;color:#92400e;"><strong>NOT recommended (AAP 2014/2023):</strong> Albuterol/salbutamol (no benefit in bronchiolitis), epinephrine (no evidence of benefit), systemic corticosteroids (no benefit), antibiotics (unless secondary bacterial infection), chest physiotherapy.</div>';
|
||||
html += '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">AAP Clinical Practice Guideline: Management of Bronchiolitis in Infants and Children (2014, reaffirmed 2023). RSV is the most common cause (50-80%).</p>';
|
||||
document.getElementById('bronch-result').innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
// Sub-pill navigation
|
||||
document.addEventListener('click', function(e) {
|
||||
var pill = e.target.closest('[data-resp]');
|
||||
if (pill) {
|
||||
document.querySelectorAll('[data-resp]').forEach(function(p) { p.classList.remove('active'); });
|
||||
pill.classList.add('active');
|
||||
['asthma','croup','bronchiolitis'].forEach(function(s) {
|
||||
var el = document.getElementById('resp-' + s);
|
||||
if (el) el.style.display = pill.dataset.resp === s ? '' : 'none';
|
||||
});
|
||||
}
|
||||
if (e.target.closest('[data-asthma-severity]')) asthmaManagement(e.target.closest('[data-asthma-severity]').dataset.asthmaSeverity);
|
||||
if (e.target.id === 'btn-pram-calc' || e.target.closest('#btn-pram-calc')) calcPRAM();
|
||||
if (e.target.id === 'btn-croup-calc' || e.target.closest('#btn-croup-calc')) calcCroup();
|
||||
if (e.target.id === 'btn-bronch-calc' || e.target.closest('#btn-bronch-calc')) calcBronchiolitis();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/sedation.js
|
||||
// PROCEDURAL SEDATION — agents + reversal, drugs.json-backed.
|
||||
// ============================================================
|
||||
|
||||
// Pulls in shared side-effect (window._EM) in case anything else needs it.
|
||||
import './shared.js';
|
||||
|
||||
function d(wt, mgPerKg, max) { var v = Math.round(wt * mgPerKg * 100) / 100; return max ? Math.min(v, max) : v; }
|
||||
|
||||
// Fallback inline data — mirrors drugs.json sedation section. The first
|
||||
// table's rows group by drug (Ketamine has both IV + IM lines), hence the
|
||||
// display vs name split. `reverses` marks the final 2 rows as reversal
|
||||
// agents shown in a separate table.
|
||||
var SED_FALLBACK = [
|
||||
{ name: 'Ketamine IV', display: 'Ketamine', dose_mg_per_kg_low: 1.5, dose_mg_per_kg_high: 2, max_mg_low: 100, max_mg_high: 150, unit: 'mg', route: 'IV', onset: '1 min', duration: '15-30 min', notes: 'Dissociative. Preserves airway reflexes.' },
|
||||
{ name: 'Ketamine IM', display: '', dose_mg_per_kg_low: 4, dose_mg_per_kg_high: 5, max_mg_low: 300, max_mg_high: 400, unit: 'mg', route: 'IM', onset: '3-5 min', duration: '30-60 min', notes: 'Give atropine 0.01 mg/kg to reduce secretions.' },
|
||||
{ name: 'Midazolam IV', display: 'Midazolam', dose_mg_per_kg_low: 0.05, dose_mg_per_kg_high: 0.1, max_mg_low: 2, max_mg_high: 5, unit: 'mg', route: 'IV', onset: '2-3 min', duration: '30-60 min', notes: 'Anxiolysis. Titrate q3-5 min.' },
|
||||
{ name: 'Midazolam IN/PO', display: '', dose_mg_per_kg: 0.5, max_mg: 20, unit: 'mg', route: 'IN/PO', onset: '10-15 min', duration: '30-60 min', notes: 'IN (max 10 mg / naris) or PO (max 20 mg).' },
|
||||
{ name: 'Propofol', display: 'Propofol', dose_mg_per_kg: 1, max_mg: 40, unit: 'mg', route: 'IV', onset: '30 sec', duration: '5-10 min', notes: 'Short procedures. Causes apnea — manage airway.' },
|
||||
{ name: 'Fentanyl IV', display: 'Fentanyl', dose_mg_per_kg: 1, max_mg: 100, unit: 'mcg', route: 'IV', onset: '2-3 min', duration: '30-60 min', notes: 'Analgesic. Often combined with midazolam.' },
|
||||
{ name: 'Fentanyl IN', display: '', dose_mg_per_kg: 2, max_mg: 100, unit: 'mcg', route: 'IN', onset: '5-10 min', duration: '30-60 min', notes: 'Intranasal.' },
|
||||
{ name: 'Nitrous oxide', display: 'Nitrous oxide', dose_display: '50:50 or 70:30 mix', unit: 'mix', route: 'Inhaled',onset: '2-5 min', duration: '5 min off', notes: 'Self-administered via demand valve. Anxiolysis + mild analgesia.' },
|
||||
{ name: 'Dexmedetomidine IN', display: 'Dexmedetomidine', dose_mg_per_kg_low: 2, dose_mg_per_kg_high: 3, max_mg_low: 100, max_mg_high: null, unit: 'mcg', route: 'IN', onset: '15-30 min', duration: '60-90 min', notes: 'Intranasal. No respiratory depression. Good for imaging.' },
|
||||
{ name: 'Naloxone', display: 'Naloxone', dose_mg_per_kg: 0.1, max_mg: 2, unit: 'mg', route: 'IV/IM/IN', reverses: 'Opioids (fentanyl, morphine)', notes: 'Max 2 mg. Repeat q2-3 min. Duration shorter than opioids — monitor for re-sedation.' },
|
||||
{ name: 'Flumazenil', display: 'Flumazenil', dose_mg_per_kg: 0.01, max_mg: 0.2, unit: 'mg', route: 'IV', reverses: 'Benzodiazepines (midazolam)', notes: 'Max 0.2 mg single dose. Repeat q1 min to max 1 mg total. Risk of seizures — use cautiously.' }
|
||||
];
|
||||
|
||||
function sedDrugs() {
|
||||
var s = window._DRUGS && window._DRUGS.sections && window._DRUGS.sections.sedation;
|
||||
if (s && s.drugs && s.drugs.length) {
|
||||
return s.drugs.map(function(dg) {
|
||||
var fb = SED_FALLBACK.filter(function(x) { return x.name === dg.name; })[0] || {};
|
||||
return Object.assign({}, fb, dg);
|
||||
});
|
||||
}
|
||||
return SED_FALLBACK;
|
||||
}
|
||||
|
||||
// perKg footer that matches the original inline format exactly.
|
||||
function sedPerKg(lo, hi, unit) {
|
||||
unit = unit || 'mg';
|
||||
return ' <span style="color:var(--g500);font-size:11px;">(' + lo + (hi != null ? '-' + hi : '') + ' ' + unit + '/kg)</span>';
|
||||
}
|
||||
|
||||
// Build the Dose cell text for a sedation drug (supports low/high range
|
||||
// and single-dose entries; honors custom dose_display for fixed doses).
|
||||
function sedDoseCell(wt, dg) {
|
||||
if (dg.dose_display) return dg.dose_display;
|
||||
var unitLabel = dg.unit === 'mcg' ? ' mcg' : (dg.unit === 'mix' ? '' : ' mg');
|
||||
var perKgUnit = dg.unit === 'mcg' ? 'mcg' : 'mg';
|
||||
if (dg.dose_mg_per_kg_low != null) {
|
||||
var lo = d(wt, dg.dose_mg_per_kg_low, dg.max_mg_low);
|
||||
if (dg.dose_mg_per_kg_high != null) {
|
||||
var hi = d(wt, dg.dose_mg_per_kg_high, dg.max_mg_high);
|
||||
return lo + '-' + hi + unitLabel + sedPerKg(dg.dose_mg_per_kg_low, dg.dose_mg_per_kg_high, perKgUnit);
|
||||
}
|
||||
return lo + unitLabel + sedPerKg(dg.dose_mg_per_kg_low, null, perKgUnit);
|
||||
}
|
||||
if (dg.dose_mg_per_kg != null) {
|
||||
var v = d(wt, dg.dose_mg_per_kg, dg.max_mg);
|
||||
return v + unitLabel + sedPerKg(dg.dose_mg_per_kg, null, perKgUnit);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function sedRow(wt, dg, showReverses) {
|
||||
var displayName = dg.display == null ? dg.name : dg.display;
|
||||
var nameCell = displayName
|
||||
? '<td style="font-weight:600;">' + displayName + '</td>'
|
||||
: '<td></td>';
|
||||
var dose = sedDoseCell(wt, dg);
|
||||
if (showReverses) {
|
||||
return '<tr>' + nameCell + '<td>' + dose + '</td><td>' + dg.route + '</td><td>' + (dg.reverses || '') + '</td><td style="font-size:12px;color:var(--g500);">' + dg.notes + '</td></tr>';
|
||||
}
|
||||
return '<tr>' + nameCell + '<td>' + dose + '</td><td>' + dg.route + '</td><td>' + (dg.onset || '') + '</td><td>' + (dg.duration || '') + '</td><td style="font-size:12px;color:var(--g500);">' + dg.notes + '</td></tr>';
|
||||
}
|
||||
|
||||
function calcSedation() {
|
||||
var wt = parseFloat(document.getElementById('sed-weight').value);
|
||||
var resultDiv = document.getElementById('sed-result');
|
||||
if (!wt) { resultDiv.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
|
||||
var all = sedDrugs();
|
||||
var agents = all.filter(function(dg) { return !dg.reverses; });
|
||||
var reversals = all.filter(function(dg) { return !!dg.reverses; });
|
||||
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--purple-light);border:1.5px solid var(--purple);margin-bottom:12px;"><span style="font-size:14px;font-weight:700;color:var(--purple);">Procedural Sedation — ' + wt + ' kg patient</span></div>';
|
||||
|
||||
// Sedation agents
|
||||
html += '<h4 style="font-size:14px;font-weight:700;color:var(--g800);margin:0 0 8px;">Sedation Agents</h4>';
|
||||
html += '<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;margin-bottom:16px;"><table style="width:100%;min-width:640px;border-collapse:collapse;font-size:13px;"><thead><tr style="background:var(--g100);"><th style="text-align:left;padding:6px 8px;">Drug</th><th style="padding:6px 8px;">Dose</th><th style="padding:6px 8px;">Route</th><th style="padding:6px 8px;">Onset</th><th style="padding:6px 8px;">Duration</th><th style="padding:6px 8px;">Notes</th></tr></thead><tbody>';
|
||||
html += agents.map(function(dg) { return sedRow(wt, dg, false); }).join('');
|
||||
html += '</tbody></table></div>';
|
||||
|
||||
// Reversal agents
|
||||
html += '<h4 style="font-size:14px;font-weight:700;color:var(--red);margin:0 0 8px;"><i class="fas fa-rotate-left"></i> Reversal Agents</h4>';
|
||||
html += '<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;"><table style="width:100%;min-width:560px;border-collapse:collapse;font-size:13px;"><thead><tr style="background:#fee2e2;"><th style="text-align:left;padding:6px 8px;">Drug</th><th style="padding:6px 8px;">Dose</th><th style="padding:6px 8px;">Route</th><th style="padding:6px 8px;">Reverses</th><th style="padding:6px 8px;">Notes</th></tr></thead><tbody>';
|
||||
html += reversals.map(function(dg) { return sedRow(wt, dg, true); }).join('');
|
||||
html += '</tbody></table></div>';
|
||||
|
||||
html += '<div style="margin-top:12px;padding:10px 12px;background:var(--amber-light);border-radius:6px;font-size:12px;color:#92400e;"><strong>Pre-sedation checklist:</strong> NPO status (2h clear liquids, 6h solids), consent, monitoring equipment (pulse ox, capnography, BP), resuscitation equipment at bedside, suction ready, IV access. Minimum monitoring: continuous SpO2, HR, capnography. Provider capable of managing airway must be present.</div>';
|
||||
html += '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">AAP Guidelines for Monitoring and Management of Pediatric Patients Before, During, and After Sedation. ASA Practice Guidelines for Sedation and Analgesia by Non-Anesthesiologists.</p>';
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-sed-calc' || e.target.closest('#btn-sed-calc')) calcSedation();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/seizure.js
|
||||
// SEIZURE MANAGEMENT (status epilepticus pathway).
|
||||
// Keeps its own local `d` + `row` helpers used by the refractory
|
||||
// infusion rendering.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function d(wt, mgPerKg, max) { var v = Math.round(wt * mgPerKg * 100) / 100; return max ? Math.min(v, max) : v; }
|
||||
function row(phase, drug, dose, route, notes) {
|
||||
return '<tr><td style="font-weight:600;color:var(--blue);">' + phase + '</td><td style="font-weight:600;">' + drug + '</td><td>' + dose + '</td><td>' + route + '</td><td style="font-size:12px;color:var(--g500);">' + notes + '</td></tr>';
|
||||
}
|
||||
|
||||
// Visual pathway row — time badge + step card linked by vertical line
|
||||
function stepRow(time, color, title, bodyHtml, isLast) {
|
||||
return '<div style="display:flex;gap:12px;align-items:stretch;">' +
|
||||
'<div style="flex:0 0 auto;width:70px;display:flex;flex-direction:column;align-items:center;">' +
|
||||
'<div style="background:' + color + ';color:white;padding:6px 0;border-radius:20px;font-size:12px;font-weight:700;width:60px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.15);">' + time + '</div>' +
|
||||
(isLast ? '' : '<div style="flex:1;width:3px;background:' + color + '40;margin:2px 0;"></div>') +
|
||||
'</div>' +
|
||||
'<div style="flex:1;padding:10px 14px;border-left:4px solid ' + color + ';background:' + color + '10;border-radius:0 8px 8px 0;margin-bottom:' + (isLast ? '4px' : '12px') + ';">' +
|
||||
'<div style="font-weight:700;color:' + color + ';font-size:13px;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.3px;">' + title + '</div>' +
|
||||
'<div style="font-size:13px;color:var(--g700);line-height:1.55;">' + bodyHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Fallback inline data — mirrors the non-refractory seizure entries in
|
||||
// drugs.json. Refractory infusion drips are kept inline below because
|
||||
// their dose strings (bolus → gtt range) don't map cleanly to the
|
||||
// single-row JSON schema.
|
||||
var SEIZURE_FALLBACK = [
|
||||
{ name: 'Lorazepam', phase: '1st benzo', dose_mg_per_kg: 0.1, max_mg: 4, unit: 'mg', route: 'IV / IO', label_suffix: ' <span style="color:#065f46;font-weight:600;">(IV preferred)</span>', notes: 'Slow push over 1-2 min.' },
|
||||
{ name: 'Midazolam', phase: '1st benzo', dose_mg_per_kg: 0.2, max_mg: 10, unit: 'mg', route: 'IM / IN / buccal', notes: 'Use 5 mg/mL concentrate for IN; split between nares.' },
|
||||
{ name: 'Diazepam', phase: '1st benzo', dose_mg_per_kg: 0.5, max_mg: 20, unit: 'mg', route: 'PR', notes: 'Only if no IV/IM/IN access.' },
|
||||
{ name: 'Levetiracetam', phase: '2nd-line', dose_mg_per_kg: 60, max_mg: 4500, unit: 'mg', route: 'IV over 5-15 min', label_suffix: ' <span style="color:#065f46;font-weight:600;">(preferred)</span>', notes: 'Best tolerability. No ECG monitoring needed.' },
|
||||
{ name: 'Fosphenytoin', phase: '2nd-line', dose_mg_per_kg: 20, max_mg: 1500, unit: 'mg PE', route: 'IV over 10 min', notes: 'Max rate 3 mg PE/kg/min. Monitor ECG, BP. Avoid if cardiac dysrhythmia.' },
|
||||
{ name: 'Valproic acid', phase: '2nd-line', dose_mg_per_kg: 40, max_mg: 3000, unit: 'mg', route: 'IV over 10 min', notes: '<strong>Avoid <2 yr, hepatic / mitochondrial disease, pregnancy.</strong>' },
|
||||
{ name: 'Phenobarbital', phase: '2nd-line', dose_mg_per_kg: 20, max_mg: 1000, unit: 'mg', route: 'IV over 20 min', notes: 'Last-choice 2nd line. High risk of resp depression + hypotension.' }
|
||||
];
|
||||
|
||||
function seizureDrugs() {
|
||||
var s = window._DRUGS && window._DRUGS.sections && window._DRUGS.sections.seizure;
|
||||
if (s && s.drugs && s.drugs.length) {
|
||||
return s.drugs.map(function(dg) {
|
||||
var fb = SEIZURE_FALLBACK.filter(function(x) { return x.name === dg.name; })[0] || {};
|
||||
return Object.assign({}, fb, dg);
|
||||
});
|
||||
}
|
||||
return SEIZURE_FALLBACK;
|
||||
}
|
||||
|
||||
// Build the joined S.drugRow output for one phase ("1st benzo" / "2nd-line").
|
||||
function seizureRowsByPhase(wt, phase) {
|
||||
return seizureDrugs().filter(function(dg) { return dg.phase === phase; }).map(function(dg) {
|
||||
var label = dg.name + (dg.label_suffix || '');
|
||||
var unitArg = dg.unit && dg.unit !== 'mg' ? ' ' + dg.unit : undefined;
|
||||
var dose = unitArg
|
||||
? S.dStr(wt, dg.dose_mg_per_kg, dg.max_mg, unitArg)
|
||||
: S.dStr(wt, dg.dose_mg_per_kg, dg.max_mg);
|
||||
return S.drugRow(label, dose, dg.route, dg.notes);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function calcSeizure() {
|
||||
var wt = parseFloat(document.getElementById('seizure-weight').value);
|
||||
var resultDiv = document.getElementById('seizure-result');
|
||||
if (!wt) { resultDiv.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
||||
var d10Low = Math.round(wt * 2 * 10) / 10;
|
||||
var d10High = Math.round(wt * 5 * 10) / 10;
|
||||
|
||||
var html = '<div style="padding:10px 14px;border-radius:8px;background:#fee2e2;border:1.5px solid #ef4444;margin-bottom:14px;"><strong style="color:#dc2626;font-size:14px;"><i class="fas fa-bolt"></i> Status Epilepticus Pathway — ' + wt + ' kg</strong><div style="font-size:12px;color:var(--g600);margin-top:3px;">Time = 0 at seizure onset. Do not pause between steps for benzo to take effect — act on the clock.</div></div>';
|
||||
|
||||
// 0 min — STABILIZE
|
||||
html += stepRow('0 min', '#3b82f6', 'Stabilize',
|
||||
'<strong>ABCs</strong> — position, 100% O2 via NC / NRB, suction. <strong>IV or IO</strong> × 2. Continuous SpO2, ECG, BP. ' +
|
||||
'<strong>POC glucose</strong> — if <60 mg/dL: <strong>D10W ' + d10Low + '-' + d10High + ' mL IV</strong> (2-5 mL/kg). ' +
|
||||
'<strong>Labs</strong>: CBC, CMP, Mg, Ca, Phos, VBG, lactate, AED levels, toxicology if unclear etiology. ' +
|
||||
'<strong>Consider pyridoxine</strong> 100 mg IV in infants <18 mo or INH ingestion. Temperature: treat hyperthermia aggressively.'
|
||||
);
|
||||
|
||||
// 5 min — 1ST BENZO
|
||||
html += stepRow('5 min', '#8b5cf6', '1st benzodiazepine (give once, pick by access)',
|
||||
S.drugTable(seizureRowsByPhase(wt, '1st benzo'))
|
||||
);
|
||||
|
||||
// 10 min — 2ND BENZO
|
||||
html += stepRow('10 min', '#a855f7', '2nd benzodiazepine — if still seizing',
|
||||
'Repeat same agent + dose <strong>once</strong>. Prepare 2nd-line now (don\'t wait to see if benzo works). If apnea/airway compromise: BVM, consider advanced airway.'
|
||||
);
|
||||
|
||||
// 20 min — 2ND-LINE
|
||||
html += stepRow('20 min', '#f59e0b', '2nd-line anti-epileptic — pick one (ESETT: equivalent efficacy)',
|
||||
S.drugTable(seizureRowsByPhase(wt, '2nd-line'))
|
||||
);
|
||||
|
||||
// 30 min — 2nd of 2nd-line (optional)
|
||||
html += stepRow('30 min', '#ef4444', 'Still seizing? Consider 2nd agent from 2nd-line OR proceed to refractory',
|
||||
'If the first 2nd-line drug failed, give a different 2nd-line agent <strong>OR</strong> move directly to refractory therapy. Activate ICU, prepare for intubation. Confirm etiology not reversible (electrolytes, glucose, fever, toxin).'
|
||||
);
|
||||
|
||||
// 40 min — REFRACTORY
|
||||
html += stepRow('40 min', '#dc2626', 'Refractory status — intubate + continuous infusion + continuous EEG',
|
||||
S.drugTable(
|
||||
S.drugRow('Midazolam <span style="color:#065f46;font-weight:600;">(1st-line infusion)</span>', 'Bolus ' + d(wt, 0.2, 10) + ' mg → gtt ' + d(wt, 0.05, 2) + '-' + d(wt, 0.5, 18) + ' mg/hr <span style="color:var(--g500);font-size:11px;">(bolus 0.2 mg/kg, gtt 0.05-0.5 mg/kg/hr)</span>', 'IV', 'Titrate to seizure control / burst suppression.') +
|
||||
S.drugRow('Pentobarbital', 'Bolus ' + d(wt, 5, 200) + ' mg → gtt ' + d(wt, 1, null) + '-' + d(wt, 5, null) + ' mg/hr <span style="color:var(--g500);font-size:11px;">(bolus 5 mg/kg, gtt 1-5 mg/kg/hr)</span>', 'IV', 'Causes hypotension — often need pressors.') +
|
||||
S.drugRow('Propofol', 'Bolus ' + d(wt, 2, 100) + ' mg → gtt ' + d(wt, 2, null) + '-' + d(wt, 5, null) + ' mg/hr <span style="color:var(--g500);font-size:11px;">(bolus 2 mg/kg, gtt 2-5 mg/kg/hr)</span>', 'IV', '<strong>PRIS risk in children</strong> — limit to <4 mg/kg/hr and duration <48 h.') +
|
||||
S.drugRow('Ketamine', 'Bolus ' + d(wt, 2, 100) + ' mg → gtt ' + d(wt, 1, null) + '-' + d(wt, 5, null) + ' mg/hr <span style="color:var(--g500);font-size:11px;">(bolus 2 mg/kg, gtt 1-5 mg/kg/hr)</span>', 'IV', 'NMDA antagonist — rescue. Good BP profile; useful if refractory to GABAergics.')
|
||||
) +
|
||||
'<div style="margin-top:8px;font-size:12px;color:var(--g600);"><strong>Continuous EEG within 1 h</strong> — target electrographic seizure suppression × 24-48 h, then wean. Reassess etiology: CNS imaging, LP, expanded workup.</div>'
|
||||
, true);
|
||||
|
||||
// Key points
|
||||
html += '<div style="margin-top:16px;padding:10px 12px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;line-height:1.55;"><strong>Key points</strong><br>' +
|
||||
'• Do <strong>NOT</strong> wait for benzo response before starting the 2nd-line — parallel preparation.<br>' +
|
||||
'• Maximum <strong>2 benzo doses</strong> total (1 pre-hospital + 1 in ED, or 2 in ED).<br>' +
|
||||
'• Respiratory depression is common — have BVM, airway equipment, naloxone, flumazenil accessible (but avoid flumazenil here).<br>' +
|
||||
'• ESETT trial: levetiracetam = fosphenytoin = valproate for efficacy. Pick by tolerability / contraindications.<br>' +
|
||||
'• Always reassess: ongoing seizure? Non-convulsive status? Pseudo-seizure? → continuous EEG if any doubt.</div>';
|
||||
|
||||
html += '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;font-style:italic;">Based on AES Guidelines 2016 and ESETT (Kapur et al., NEJM 2019). Verify all doses against institutional protocols.</p>';
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-seizure-calc' || e.target.closest('#btn-seizure-calc')) calcSeizure();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/sepsis.js
|
||||
// SEPSIS — Phoenix criteria, febrile-infant rules, first-hour bundle.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function showSepsis() {
|
||||
var wt = parseFloat(document.getElementById('sepsis-weight').value);
|
||||
var age = document.getElementById('sepsis-age').value;
|
||||
var el = document.getElementById('sepsis-result');
|
||||
var html = '';
|
||||
|
||||
var ageLbl = { neonate: 'Neonate (0-28 d)', infant: 'Young infant (29 d - 3 mo)', child: 'Older child / adolescent' }[age];
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:#fee2e2;border:1.5px solid #ef4444;margin-bottom:12px;"><strong style="color:#dc2626;">Sepsis approach — ' + ageLbl + (wt ? ', ' + wt + ' kg' : '') + '</strong></div>';
|
||||
|
||||
// Definition (Phoenix 2024)
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:0 0 6px;">Definition — Phoenix Sepsis Criteria (JAMA 2024)</h5>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;line-height:1.55;margin-bottom:12px;">';
|
||||
html += '<strong>Sepsis</strong> = suspected or confirmed infection + Phoenix Score ≥2 (organ dysfunction across respiratory, cardiovascular, coagulation, neurological).<br>';
|
||||
html += '<strong>Septic shock</strong> = sepsis + cardiovascular dysfunction (vasoactive support, or ↑lactate ≥5, or ↓MAP for age).<br>';
|
||||
html += '<em style="color:var(--g500);">Previous SIRS-based criteria (Goldstein 2005) are now superseded. Still useful for quick clinical screening.</em>';
|
||||
html += '</div>';
|
||||
|
||||
// Quick red flags
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:10px 0 6px;">Red flags (recognize early)</h5>';
|
||||
html += '<div style="padding:10px;background:#fef2f2;border-radius:6px;font-size:12px;color:#991b1b;">Abnormal behavior / mentation • Fever + ill-appearance • Tachycardia out of proportion to fever • Prolonged cap refill (>3 s) • Cold/mottled extremities • Weak pulses or wide pulse pressure ("warm shock") • Hypotension is a <strong>LATE</strong> sign in children • Any immune compromise / indwelling line.</div>';
|
||||
|
||||
// Age-specific approach
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Evaluation & empiric therapy — ' + ageLbl + '</h5>';
|
||||
if (age === 'neonate') {
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;margin-bottom:10px;">';
|
||||
html += '<strong>Workup (full sepsis eval):</strong> CBC + diff, CRP, blood culture, UA + urine culture (cath), <strong>lumbar puncture</strong> (CSF + HSV PCR), CXR if respiratory sx. Consider procalcitonin. Rapid viral panel if available.<br>';
|
||||
html += '<strong>Early-onset (<72 h):</strong> GBS, E. coli, Listeria. <strong>Late-onset (>72 h):</strong> coag-neg staph, S. aureus, gram-negs, Candida.</div>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Ampicillin', wt ? S.dStr(wt, 100, 2000) : '50-100 mg/kg/dose', 'IV', 'q8-12h depending on age/weight. Covers GBS, Listeria, Enterococcus.') +
|
||||
S.drugRow('Gentamicin', wt ? S.dStr(wt, 4, 120) : '4-5 mg/kg/dose', 'IV', 'q24-48h. Monitor levels. Peak 5-12, trough <1.') +
|
||||
S.drugRow('Cefotaxime (add)', wt ? S.dStr(wt, 50, 2000) : '50 mg/kg/dose', 'IV', 'If meningitis suspected or gram-neg concern. Ceftriaxone avoided in hyperbilirubinemia.') +
|
||||
S.drugRow('Acyclovir', wt ? S.dStr(wt, 20, 1200) : '20 mg/kg/dose', 'IV q8h', 'If HSV risk: maternal genital lesions, vesicles, seizures, CSF pleocytosis, hypothermia, hepatitis.')
|
||||
);
|
||||
} else if (age === 'infant') {
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;margin-bottom:10px;">';
|
||||
html += '<strong>Workup:</strong> Use validated rules — PECARN, Aronson, Rochester, Step-by-Step (see below). Most warrant CBC + ANC, procalcitonin and/or CRP, blood culture, UA + urine culture. Many warrant LP + admission + empiric abx.<br>';
|
||||
html += '<strong>Coverage:</strong> GBS, E. coli, Listeria (up to ~6 wk), S. pneumoniae, N. meningitidis, H. influenzae, Salmonella.</div>';
|
||||
|
||||
// Febrile-infant rules — collapsible details
|
||||
html += '<details style="margin-bottom:10px;border:1px solid var(--g200);border-radius:8px;padding:8px 10px;background:white;"><summary style="cursor:pointer;font-weight:600;font-size:13px;color:var(--blue-dark);"><i class="fas fa-list-check"></i> Validated febrile-infant rules (click to expand)</summary>';
|
||||
html += '<div style="margin-top:10px;font-size:12px;line-height:1.55;">';
|
||||
|
||||
// PECARN 2019
|
||||
html += '<div style="padding:10px 12px;background:#eff6ff;border-radius:6px;margin-bottom:8px;border-left:4px solid #3b82f6;">';
|
||||
html += '<div style="font-weight:700;color:#1e40af;margin-bottom:4px;">PECARN febrile infant rule (Kuppermann et al., JAMA Pediatrics 2019)</div>';
|
||||
html += '<strong>Age:</strong> 29-60 days, well-appearing, febrile. <strong>Low-risk if ALL:</strong><br>';
|
||||
html += ' • Normal UA (no LE, no nitrite, <5 WBC/hpf)<br>';
|
||||
html += ' • ANC ≤4,090 /μL<br>';
|
||||
html += ' • Procalcitonin ≤0.5 ng/mL (if procal unavailable: use CRP ≤20 mg/L as proxy)<br>';
|
||||
html += '<strong>Low-risk</strong> → may defer LP/abx, observe, close follow-up. <strong>Not low-risk</strong> → full workup + empiric abx.';
|
||||
html += '</div>';
|
||||
|
||||
// Aronson 2019
|
||||
html += '<div style="padding:10px 12px;background:#f0fdf4;border-radius:6px;margin-bottom:8px;border-left:4px solid #16a34a;">';
|
||||
html += '<div style="font-weight:700;color:#166534;margin-bottom:4px;">Aronson rule (Aronson et al., PEDIATRICS 2019)</div>';
|
||||
html += '<strong>Age:</strong> 8-60 days, febrile ≥38°C. Point-based score for invasive bacterial infection (IBI):<br>';
|
||||
html += ' • Age 22-28 days: <strong>+1</strong><br>';
|
||||
html += ' • Max temperature ≥38.5°C: <strong>+2</strong><br>';
|
||||
html += ' • ANC ≥5,185 /μL: <strong>+2</strong><br>';
|
||||
html += ' • Abnormal UA: <strong>+3</strong><br>';
|
||||
html += '<strong>Score 0-1</strong> → low risk. <strong>≥2</strong> → consider full workup + abx. Higher scores → higher IBI risk.';
|
||||
html += '</div>';
|
||||
|
||||
// Rochester
|
||||
html += '<div style="padding:10px 12px;background:#fffbeb;border-radius:6px;margin-bottom:8px;border-left:4px solid #f59e0b;">';
|
||||
html += '<div style="font-weight:700;color:#92400e;margin-bottom:4px;">Rochester criteria (Jaskiewicz et al., Pediatrics 1994)</div>';
|
||||
html += '<strong>Age:</strong> 0-60 days, febrile. <strong>Low-risk if ALL:</strong><br>';
|
||||
html += ' • Previously healthy (term, no perinatal complications, no prior abx/hospitalization)<br>';
|
||||
html += ' • Well-appearing, no focal infection (not skin/bone/joint/soft tissue)<br>';
|
||||
html += ' • WBC 5,000-15,000 /μL and absolute band count ≤1,500 /μL<br>';
|
||||
html += ' • UA ≤10 WBC/hpf<br>';
|
||||
html += ' • If diarrhea: stool ≤5 WBC/hpf<br>';
|
||||
html += '<strong>Low-risk</strong> → observation without abx possible (NPV >98%). Does not include procal.';
|
||||
html += '</div>';
|
||||
|
||||
// Step-by-Step
|
||||
html += '<div style="padding:10px 12px;background:#fdf4ff;border-radius:6px;margin-bottom:4px;border-left:4px solid #a855f7;">';
|
||||
html += '<div style="font-weight:700;color:#6b21a8;margin-bottom:4px;">Step-by-Step (Gomez et al., Pediatrics 2016 — European)</div>';
|
||||
html += '<strong>Age:</strong> ≤90 days, febrile. Sequential triage; stop at first positive:<br>';
|
||||
html += ' <strong>1.</strong> Ill-appearing? → <em>high risk</em><br>';
|
||||
html += ' <strong>2.</strong> Age ≤21 days? → <em>high risk</em><br>';
|
||||
html += ' <strong>3.</strong> Leukocyturia (UA abnormal)? → <em>high risk</em><br>';
|
||||
html += ' <strong>4.</strong> Procalcitonin ≥0.5 ng/mL? → <em>high risk</em><br>';
|
||||
html += ' <strong>5.</strong> CRP >20 mg/L AND/OR ANC >10,000 /μL? → <em>intermediate</em><br>';
|
||||
html += ' <strong>6.</strong> Otherwise → <em>low risk</em>';
|
||||
html += '<div style="margin-top:4px;color:var(--g500);">Highest sensitivity for IBI among these rules when procal available.</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div style="margin-top:8px;padding:8px 10px;background:var(--g50);border-radius:6px;font-size:11px;color:var(--g600);"><strong>Practical note:</strong> AAP 2021 Clinical Practice Guideline on the well-appearing febrile infant 8-60 days synthesizes these — ages 8-21 d full workup regardless, 22-28 d inflammatory markers guide LP, 29-60 d UA + markers guide LP/abx. Defer to your local institutional pathway if available.</div>';
|
||||
html += '</div></details>';
|
||||
|
||||
html += S.drugTable(
|
||||
S.drugRow('Ceftriaxone', wt ? S.dStr(wt, 75, 2000) : '50-75 mg/kg/dose', 'IV / IM', 'q24h (or 100 mg/kg/day divided q12h for meningitis). <strong>Avoid <28 days</strong> if hyperbilirubinemia.') +
|
||||
S.drugRow('Ampicillin', wt ? S.dStr(wt, 100, 2000) : '50-100 mg/kg/dose', 'IV', 'If <6 wks: add for Listeria coverage.') +
|
||||
S.drugRow('Vancomycin', wt ? S.dStr(wt, 15, 1000) : '15 mg/kg/dose', 'IV', 'If severe / MRSA risk / meningitis. Target trough 15-20.') +
|
||||
S.drugRow('Acyclovir', wt ? S.dStr(wt, 20, 1200) : '20 mg/kg/dose', 'IV q8h', '<6 weeks with suspicion of HSV.')
|
||||
);
|
||||
} else {
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;margin-bottom:10px;">';
|
||||
html += '<strong>Recognition:</strong> Phoenix score or clinical concern + suspected infection. <strong>Workup:</strong> CBC, CRP, procalcitonin, blood cx (+ site-specific cx), lactate, blood gas, glucose, electrolytes, coags, LP if CNS concern. Source-directed imaging.<br>';
|
||||
html += '<strong>Common sources:</strong> respiratory, UTI, CNS, skin/soft tissue, intra-abdominal, bone/joint, indwelling device.</div>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Ceftriaxone', wt ? S.dStr(wt, 50, 2000) : '50 mg/kg/dose', 'IV', 'q24h (or 100 mg/kg/day divided for meningitis).') +
|
||||
S.drugRow('Vancomycin', wt ? S.dStr(wt, 15, 1000) : '15 mg/kg/dose', 'IV', 'q6h. If severe, indwelling line, or MRSA prevalence >10%.') +
|
||||
S.drugRow('Piperacillin-tazobactam', wt ? S.dStr(wt, 100, 4500) : '80-100 mg/kg/dose', 'IV', 'If intra-abdominal / neutropenic.') +
|
||||
S.drugRow('Clindamycin', wt ? S.dStr(wt, 10, 900) : '10-13 mg/kg/dose', 'IV', 'Adjunct for toxic shock syndrome (toxin suppression).') +
|
||||
S.drugRow('Acyclovir', wt ? S.dStr(wt, 20, 1200) : '10-20 mg/kg/dose', 'IV q8h', 'If HSV CNS concern.')
|
||||
);
|
||||
}
|
||||
|
||||
// First-hour bundle
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">First-hour bundle (SSC Peds 2020)</h5>';
|
||||
html += S.stepBox('#3b82f6', '0-5 min — Recognize', 'Screen for sepsis; sepsis huddle / activation. Assess ABCs. O2 to SpO2 >94%. Warm patient.');
|
||||
html += S.stepBox('#8b5cf6', '5-15 min — Access & labs', 'Two IVs or IO. Draw: blood cx (ideally before abx), lactate, CBC, CMP, coags, blood gas, glucose. UA + culture. Source-specific cultures.');
|
||||
html += S.stepBox('#f59e0b', '15-30 min — Fluids', (wt ? 'NS or LR <strong>' + S.d(wt, 20) + ' mL</strong> bolus over 5-10 min (20 mL/kg). ' : 'NS/LR 10-20 mL/kg bolus over 5-10 min. ') + 'Reassess after each bolus: HR, perfusion, lung sounds, liver size. Repeat up to 40-60 mL/kg; stop if crackles or hepatomegaly.');
|
||||
html += S.stepBox('#10b981', '30-60 min — Antibiotics + reassess', 'Broad-spectrum empiric abx within 1 hour (ideally 3 hr in sepsis, ≤1 hr in septic shock). Recheck lactate, perfusion.');
|
||||
html += S.stepBox('#ef4444', '>60 min — Fluid-refractory shock', 'Start vasoactive (<strong>epinephrine 0.05-0.3 mcg/kg/min</strong> for cold shock or <strong>norepinephrine 0.05-0.3 mcg/kg/min</strong> for warm shock). Consider central/IO access. Stress-dose hydrocortisone ' + (wt ? '<strong>' + S.d(wt, 2, 100) + ' mg</strong> IV <span style="color:var(--g500);font-size:11px;">(2 mg/kg, max 100 mg)</span>' : '2 mg/kg IV (max 100 mg)') + ' if catecholamine-resistant. Consider ICU transfer.');
|
||||
|
||||
// Key targets
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Resuscitation targets</h5>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;">Normal mentation • Cap refill ≤2 s • Warm extremities • Strong peripheral pulses • UOP ≥1 mL/kg/hr • MAP ≥5th %ile for age (>65 mmHg adolescent) • SpO2 ≥94% • Lactate trending down.</div>';
|
||||
html += S.ref('Phoenix Sepsis Criteria (Schlapbach et al., JAMA 2024). Surviving Sepsis Campaign Pediatric 2020. AAP pediatric sepsis guidance.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-sepsis-show' || e.target.closest('#btn-sepsis-show')) showSepsis();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/shared.js
|
||||
// Shared string-building helpers used by every Bedside section.
|
||||
// Pure functions with no side effects. Exports `S`; also sets
|
||||
// window._EM = S for back-compat with any stray lookups.
|
||||
// ============================================================
|
||||
|
||||
export const S = {
|
||||
d: function(wt, perKg, max) { var v = Math.round(wt * perKg * 100) / 100; return max ? Math.min(v, max) : v; },
|
||||
// Dose string — always shows the per-kg used (and max when capped), so the
|
||||
// prescriber can verify the math without opening a drug ref.
|
||||
// S.dStr(30, 0.1, 4) → "3 mg (0.1 mg/kg, max 4 mg)"
|
||||
// S.dStr(60, 0.1, 4) → "4 mg (0.1 mg/kg, max 4 mg · capped)"
|
||||
// S.dStr(20, 2, 150, ' mcg') → "40 mcg (2 mcg/kg, max 150 mcg)"
|
||||
dStr: function(wt, perKg, max, unit) {
|
||||
unit = unit || ' mg';
|
||||
var uTrim = unit.trim() || 'mg';
|
||||
var raw = wt * perKg;
|
||||
var v = Math.round(raw * 100) / 100;
|
||||
var capped = max != null && v > max;
|
||||
var val = capped ? max : v;
|
||||
var perKgStr = '(' + perKg + ' ' + uTrim + '/kg';
|
||||
if (max != null) perKgStr += ', max ' + max + ' ' + uTrim;
|
||||
if (capped) perKgStr += ' · capped';
|
||||
perKgStr += ')';
|
||||
return val + unit + ' <span style="color:var(--g500);font-size:11px;font-weight:400;">' + perKgStr + '</span>';
|
||||
},
|
||||
drugTable: function(rows) {
|
||||
return '<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;margin:8px 0;"><table style="width:100%;min-width:500px;border-collapse:collapse;font-size:13px;"><thead><tr style="background:var(--g100);"><th style="text-align:left;padding:6px 8px;">Drug</th><th style="text-align:left;padding:6px 8px;">Dose</th><th style="text-align:left;padding:6px 8px;">Route</th><th style="text-align:left;padding:6px 8px;">Notes</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||||
},
|
||||
drugRow: function(name, dose, route, notes) {
|
||||
return '<tr><td style="font-weight:600;padding:5px 8px;border-bottom:1px solid var(--g100);">' + name + '</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">' + dose + '</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">' + route + '</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);font-size:12px;color:var(--g500);">' + (notes || '') + '</td></tr>';
|
||||
},
|
||||
stepBox: function(color, title, body) {
|
||||
return '<div style="border-left:4px solid ' + color + ';background:' + color + '10;padding:10px 14px;border-radius:6px;margin-bottom:10px;">' +
|
||||
'<div style="font-weight:700;color:' + color + ';font-size:13px;margin-bottom:4px;">' + title + '</div>' +
|
||||
'<div style="font-size:13px;color:var(--g700);line-height:1.5;">' + body + '</div></div>';
|
||||
},
|
||||
arrow: '<div style="text-align:center;color:var(--g400);font-size:16px;margin:-4px 0 6px;">↓</div>',
|
||||
badge: function(label, color) { return '<span style="display:inline-block;padding:3px 10px;border-radius:6px;background:' + color + '20;color:' + color + ';font-weight:700;font-size:12px;">' + label + '</span>'; },
|
||||
ref: function(text) { return '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;font-style:italic;">' + text + '</p>'; }
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') window._EM = S;
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/sub-nav.js
|
||||
// EMERGENCIES SUB-NAV — data-em pill switching.
|
||||
// Persists the active pill so reloads / sign-out-sign-in land on the
|
||||
// same sub-section the user was last looking at.
|
||||
// ============================================================
|
||||
|
||||
var SECTIONS = ['neonatal','airway','cardiac','respiratory','ventilation','seizure','sepsis','anaphylaxis','sedation','agitation','antiemetics','antimicrobials','burns','toxicology','trauma'];
|
||||
|
||||
function activate(emKey) {
|
||||
var pill = document.querySelector('[data-em="' + emKey + '"]');
|
||||
if (!pill) return false;
|
||||
document.querySelectorAll('[data-em]').forEach(function(p) { p.classList.remove('active'); });
|
||||
pill.classList.add('active');
|
||||
SECTIONS.forEach(function(s) {
|
||||
var el = document.getElementById('em-' + s);
|
||||
if (el) el.style.display = emKey === s ? '' : 'none';
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
var pill = e.target.closest('[data-em]');
|
||||
if (!pill) return;
|
||||
activate(pill.dataset.em);
|
||||
if (window.UIState) window.UIState.set('bedside.pill', pill.dataset.em);
|
||||
});
|
||||
// Restore on load. The bedside component is lazily injected the first time
|
||||
// the tab is activated, so try both an immediate pass (harness where the
|
||||
// DOM is synchronously ready) and a tabChanged listener (live app where
|
||||
// the component HTML arrives async). Either path reaches the same
|
||||
// activate(saved) call — whichever fires first wins; the other is a noop.
|
||||
function applySaved() {
|
||||
if (!window.UIState) return;
|
||||
var saved = window.UIState.get('bedside.pill');
|
||||
if (saved) activate(saved);
|
||||
}
|
||||
applySaved();
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail && e.detail.tab === 'bedside') applySaved();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/toxicology.js
|
||||
// TOXICOLOGY.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function lookupTox() {
|
||||
var wt = parseFloat(document.getElementById('tox-weight').value);
|
||||
var topic = document.getElementById('tox-topic').value;
|
||||
var el = document.getElementById('tox-result');
|
||||
var html = '';
|
||||
|
||||
if (topic === 'approach') {
|
||||
html += S.stepBox('#3b82f6', '1. Stabilize (ABCDE)', 'Airway, breathing, circulation, disability (glucose, pupils, GCS), exposure. Naloxone if depressed LOC + resp depression. Dextrose if hypoglycemic. Thiamine in select cases.');
|
||||
html += S.stepBox('#8b5cf6', '2. Identify toxidrome', '<strong>Sympathomimetic:</strong> HTN, tachy, mydriasis, diaphoresis (cocaine, meth). <strong>Anticholinergic:</strong> hot/dry, mydriasis, tachy, delirium (antihistamines, TCAs). <strong>Cholinergic:</strong> SLUDGE-M, miosis (organophosphates). <strong>Opioid:</strong> miosis, resp depression, ↓LOC. <strong>Sedative-hypnotic:</strong> ↓LOC, normal/low vitals.');
|
||||
html += S.stepBox('#f59e0b', '3. Decontamination', '<strong>Activated charcoal</strong> 1 g/kg PO (max 50 g): within 1h of ingestion, intact airway, ingestion adsorbed (not Li, metals, alcohols). Avoid caustics/hydrocarbons. <strong>Whole bowel irrigation:</strong> PEG for metals/iron/lithium/sustained release. <strong>Gastric lavage:</strong> rarely indicated. <strong>Ipecac:</strong> no longer recommended.');
|
||||
html += S.stepBox('#10b981', '4. Enhance elimination', '<strong>Urinary alkalinization</strong> (salicylates, phenobarbital). <strong>Hemodialysis</strong> (see ISTUMBLE). <strong>Lipid emulsion</strong> for lipid-soluble drug toxicity (LA, CCB, TCA).');
|
||||
html += S.stepBox('#ef4444', '5. Antidotes', 'See topic list. <strong>Contact Poison Center</strong> (US: 1-800-222-1222) early for any significant exposure.');
|
||||
} else if (topic === 'acetaminophen') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>Acetaminophen overdose</strong></div>';
|
||||
html += '<p style="font-size:13px;">Acute toxic dose: >150 mg/kg (or 7.5 g total) single ingestion. Hepatotoxic after 24h; AST/ALT peak day 3-4.</p>';
|
||||
html += '<p style="font-size:13px;"><strong>Diagnosis:</strong> Draw level at 4h post-ingestion (or on arrival if >4h). Plot on <strong>Rumack-Matthew nomogram</strong> (treatment line at 150 mcg/mL at 4h).</p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('N-acetylcysteine (NAC) — IV', '150 mg/kg over 1h → 50 mg/kg over 4h → 100 mg/kg over 16h (21h total)', 'IV', 'Total 300 mg/kg. Most effective if started <8h post-ingestion.') +
|
||||
S.drugRow('N-acetylcysteine — PO', '140 mg/kg load, then 70 mg/kg q4h × 17 doses', 'PO', 'Alternative to IV. Unpleasant taste — often with juice.') +
|
||||
S.drugRow('Activated charcoal', '1 g/kg (max 50 g)', 'PO', 'If within 1-2h of ingestion and airway protected.')
|
||||
);
|
||||
if (wt) html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>For ' + wt + ' kg:</strong> IV loading = ' + S.d(wt, 150) + ' mg over 1h <span style="color:var(--g500);font-size:11px;">(150 mg/kg)</span>; 2nd bag = ' + S.d(wt, 50) + ' mg over 4h <span style="color:var(--g500);font-size:11px;">(50 mg/kg)</span>; 3rd bag = ' + S.d(wt, 100) + ' mg over 16h <span style="color:var(--g500);font-size:11px;">(100 mg/kg)</span>. <em>Total 300 mg/kg.</em></div>';
|
||||
} else if (topic === 'opioids') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>Opioid overdose</strong></div>';
|
||||
html += '<p style="font-size:13px;">Triad: <strong>miosis + respiratory depression + ↓ LOC</strong>. Fentanyl / synthetics may require repeated/higher doses.</p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Naloxone (initial)', '0.01-0.1 mg/kg' + (wt ? ' = ' + S.d(wt, 0.01) + '-' + S.d(wt, 0.1) + ' mg' : ''), 'IV / IM / IN / IO', 'Start low if chronic opioid use (avoid withdrawal). Titrate to respiratory effort, not consciousness.') +
|
||||
S.drugRow('Naloxone (full reversal)', '2 mg IV/IM/IN', 'IV / IM / IN', 'If no chronic use and severe resp depression. Repeat q2-3 min.') +
|
||||
S.drugRow('Naloxone infusion', '2/3 of effective bolus per hour', 'IV', 'For long-acting opioids (methadone, fentanyl patch, sustained release)')
|
||||
);
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;margin-top:10px;">Observe ≥4h after last dose; longer for long-acting agents. Duration of naloxone (30-90 min) is shorter than most opioids — <strong>re-sedation is common</strong>.</div>';
|
||||
} else if (topic === 'iron') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>Iron overdose</strong></div>';
|
||||
html += '<p style="font-size:13px;">Toxic dose: elemental Fe ≥20 mg/kg. Severe ≥60 mg/kg. <strong>Charcoal does NOT bind iron.</strong></p>';
|
||||
html += '<p style="font-size:13px;"><strong>Stages:</strong> 1) GI (0-6h: vomiting, bloody diarrhea); 2) Latent (6-24h: deceptive improvement); 3) Shock/metabolic acidosis (12-24h); 4) Hepatotoxicity (2-5d); 5) Gastric scarring (2-8 wk).</p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Whole bowel irrigation', 'PEG-ES 25-40 mL/kg/hr', 'NG', 'For radiopaque pills on KUB or ingested sustained-release iron.') +
|
||||
S.drugRow('Deferoxamine', '15 mg/kg/hr IV infusion', 'IV', 'Max 6-8 g/day. Indications: shock, metabolic acidosis, Fe level >500 mcg/dL, or severe symptoms. Urine turns "vin rosé" color.')
|
||||
);
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;margin-top:10px;">Iron level at 4-6h. <350 usually asymptomatic; 350-500 mild; >500 severe. Consider KUB for radiopaque pills.</div>';
|
||||
} else if (topic === 'tca') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>TCA overdose</strong></div>';
|
||||
html += '<p style="font-size:13px;">Anticholinergic + Na-channel blockade. <strong>Risks:</strong> seizure, VT/VF, hypotension, coma. ECG: QRS >100 ms or R in aVR >3 mm predicts toxicity.</p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Sodium bicarbonate 8.4%', '1-2 mEq/kg IV bolus → infusion', 'IV', 'Goal pH 7.45-7.55 and narrowing QRS. Repeat bolus for QRS widening, hypotension, or arrhythmia.') +
|
||||
S.drugRow('Mg sulfate', S.dStr(wt || 30, 50, 2000, ' mg'), 'IV over 10 min', 'For torsades.') +
|
||||
S.drugRow('IV lipid emulsion 20%', '1.5 mL/kg bolus → 0.25 mL/kg/min × 30-60 min', 'IV', 'If refractory shock/arrest. Consult toxicology.')
|
||||
);
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;margin-top:10px;"><strong>Avoid</strong> Class IA/IC antiarrhythmics, beta-blockers, physostigmine (risk of asystole with TCAs).</div>';
|
||||
} else if (topic === 'bbccb') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>β-blocker / Calcium-channel blocker overdose</strong></div>';
|
||||
html += '<p style="font-size:13px;">Bradycardia + hypotension. CCBs (esp. verapamil, diltiazem, amlodipine) can be lethal in small pediatric doses.</p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('IV fluids', '20 mL/kg NS bolus', 'IV', 'Cautious — avoid volume overload') +
|
||||
S.drugRow('Calcium chloride 10% or gluconate', 'CaCl 20 mg/kg / Ca-glu 60 mg/kg', 'IV', 'First-line for CCB. Repeat q15-20 min.') +
|
||||
S.drugRow('Glucagon', '50 mcg/kg IV bolus → 50-150 mcg/kg/hr', 'IV', 'β-blocker antidote. GI side effects common.') +
|
||||
S.drugRow('High-dose insulin (HIE)', 'Regular insulin 1 U/kg bolus → 0.5-2 U/kg/hr + D25% infusion', 'IV', 'Hyperinsulinemic euglycemia therapy. Monitor K+ and glucose closely.') +
|
||||
S.drugRow('Vasopressors', 'Epi / norepi infusion', 'IV', 'Titrate to MAP.') +
|
||||
S.drugRow('Lipid emulsion 20%', '1.5 mL/kg → 0.25 mL/kg/min', 'IV', 'For lipid-soluble CCBs (verapamil) if refractory.') +
|
||||
S.drugRow('Methylene blue / ECMO', '—', '—', 'Rescue therapy — toxicology/ICU consult.')
|
||||
);
|
||||
} else if (topic === 'benzo') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>Benzodiazepine overdose</strong></div>';
|
||||
html += '<p style="font-size:13px;">Sedation + resp depression. Usually supportive — intubation rarely needed alone. <strong>Flumazenil caution.</strong></p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Flumazenil', '0.01 mg/kg (max 0.2 mg) IV, may repeat q1 min to max 1 mg', 'IV', 'Only for iatrogenic reversal in a benzo-naive patient with no TCAs, seizure disorder, or chronic benzo use. <strong>Can precipitate seizures.</strong>')
|
||||
);
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;margin-top:10px;">In real-world ingestion, <strong>supportive care (airway, monitoring)</strong> is usually safer than flumazenil.</div>';
|
||||
} else if (topic === 'organo') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>Organophosphate / carbamate poisoning</strong></div>';
|
||||
html += '<p style="font-size:13px;">Cholinergic toxidrome: <strong>SLUDGE-M</strong> (salivation, lacrimation, urination, defecation, GI distress, emesis, miosis) + muscle fasciculation, bradycardia, bronchorrhea. Decontamination critical (PPE for providers).</p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Atropine', '0.05 mg/kg IV, double q3-5 min until bronchial secretions dry', 'IV', 'Endpoint = dry lungs, not dry mouth or heart rate. Can require huge doses.') +
|
||||
S.drugRow('Pralidoxime (2-PAM)', '25-50 mg/kg IV over 30 min (max 2 g) → 10-20 mg/kg/hr', 'IV', 'Only for organophosphates (not carbamates). Regenerates acetylcholinesterase.') +
|
||||
S.drugRow('Diazepam / midazolam', 'Standard seizure doses', 'IV', 'For seizures or severe fasciculations.')
|
||||
);
|
||||
} else if (topic === 'salicylate') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>Salicylate overdose</strong></div>';
|
||||
html += '<p style="font-size:13px;">Mixed respiratory alkalosis + anion gap metabolic acidosis. Tinnitus, tachypnea, diaphoresis, hyperthermia. Severe: CNS depression, seizures, pulmonary edema, cerebral edema.</p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Fluids', 'Aggressive resuscitation', 'IV', 'Dehydration common. Avoid fluid overload (risk of pulmonary edema).') +
|
||||
S.drugRow('Sodium bicarb', '1-2 mEq/kg IV bolus → drip', 'IV', 'Urinary alkalinization (goal urine pH >7.5). Prevents CNS entry.') +
|
||||
S.drugRow('Glucose', 'Maintain euglycemia even if BG normal', 'IV', 'CNS hypoglycemia despite normal serum glucose.') +
|
||||
S.drugRow('Hemodialysis', '—', '—', 'For severe: altered MS, pulmonary edema, renal failure, refractory acidosis, or level >100 mg/dL acute / >60 chronic.')
|
||||
);
|
||||
} else if (topic === 'etoh') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>Toxic alcohols (methanol / ethylene glycol)</strong></div>';
|
||||
html += '<p style="font-size:13px;">Anion gap metabolic acidosis + osmolar gap. Methanol → visual changes, blindness. Ethylene glycol → calcium oxalate crystals in urine, renal failure.</p>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Fomepizole', '15 mg/kg IV load → 10 mg/kg q12h × 4 → 15 mg/kg q12h', 'IV', 'First-line. Inhibits alcohol dehydrogenase.') +
|
||||
S.drugRow('Ethanol (alternative)', 'Load 600 mg/kg → maintain level 100-150 mg/dL', 'IV / PO', 'If fomepizole unavailable. Monitor closely.') +
|
||||
S.drugRow('Hemodialysis', '—', '—', 'For severe acidosis, end-organ damage, or high levels.') +
|
||||
S.drugRow('Folate / thiamine / pyridoxine', 'Standard doses', 'IV', 'Methanol: folate. EG: thiamine + pyridoxine (divert to non-toxic metabolites).')
|
||||
);
|
||||
} else if (topic === 'dialyzable') {
|
||||
html += '<div style="padding:10px 14px;border-radius:8px;background:var(--blue-light);border:1.5px solid var(--blue);margin-bottom:10px;"><strong>Dialyzable drugs (ISTUMBLE)</strong></div>';
|
||||
html += '<p style="font-size:13px;">Commonly dialyzable in overdose:</p>';
|
||||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:6px;font-size:13px;">';
|
||||
[['I','Isopropyl alcohol, INH'],['S','Salicylates'],['T','Theophylline, Toxic alcohols'],['U','Uremia-related drugs'],['M','Methanol, Metformin, Methotrexate'],['B','Barbiturates (long-acting)'],['L','Lithium'],['E','Ethylene glycol']].forEach(function(x) {
|
||||
html += '<div style="padding:8px 10px;background:var(--g50);border-radius:6px;"><strong>' + x[0] + ':</strong> ' + x[1] + '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;margin-top:10px;"><strong>Poor candidates for HD:</strong> Large Vd (TCAs, digoxin, BBs), highly protein-bound (benzos, CCBs), lipid-soluble (opioids, phenothiazines). EXTRIP recommendations are evidence-based — consult toxicology.</div>';
|
||||
}
|
||||
html += S.ref('Call Poison Control early: 1-800-222-1222 (US). Refs: Goldfrank\'s Toxicologic Emergencies, EXTRIP workgroup.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-tox-lookup' || e.target.closest('#btn-tox-lookup')) lookupTox();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/trauma.js
|
||||
// TRAUMA — primary survey, MTP, c-spine, shock signs.
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function showTrauma() {
|
||||
var wt = parseFloat(document.getElementById('trauma-weight').value);
|
||||
var el = document.getElementById('trauma-result');
|
||||
var html = '';
|
||||
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:0 0 6px;">Primary Survey — ABCDE</h5>';
|
||||
html += S.stepBox('#ef4444', 'A — Airway + c-spine', 'Maintain airway (jaw thrust, suction). <strong>Manual inline stabilization</strong>; apply collar. Intubate if GCS ≤8, inadequate airway, or impending compromise. Avoid succinylcholine if burn/crush/prolonged immobilization.');
|
||||
html += S.stepBox('#f59e0b', 'B — Breathing', 'SpO2, bilateral breath sounds, chest wall integrity. Identify tension PTX (needle decompression), open PTX (3-sided dressing), flail chest, massive hemothorax.');
|
||||
html += S.stepBox('#10b981', 'C — Circulation', 'Two large-bore IVs / IO. Control hemorrhage (direct pressure, tourniquet, pelvic binder). ' + (wt ? 'NS or LR 20 mL/kg = ' + S.d(wt, 20) + ' mL bolus.' : 'NS/LR 20 mL/kg bolus.') + ' Consider blood after 40-60 mL/kg crystalloid or in Class III shock.');
|
||||
html += S.stepBox('#3b82f6', 'D — Disability', 'GCS, pupils, glucose, gross motor. Consider ↑ICP (head up 30°, mannitol 0.5-1 g/kg or hypertonic saline 3% 3-5 mL/kg).');
|
||||
html += S.stepBox('#8b5cf6', 'E — Exposure + environment', 'Fully expose; prevent hypothermia (warm blankets, fluids).');
|
||||
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:16px 0 6px;">Massive Transfusion Protocol (MTP)</h5>';
|
||||
html += S.drugTable(
|
||||
S.drugRow('Ratio', '1:1:1 (pRBC : FFP : platelets)', '—', 'Activate early if ≥40 mL/kg transfused or ongoing hemorrhage. Avoid excessive crystalloid.') +
|
||||
S.drugRow('Tranexamic acid (TXA)', '15 mg/kg IV (max 1 g) over 10 min → 2 mg/kg/hr × 8h', 'IV', 'Within 3 hr of injury. CRASH-2 / MATIC.') +
|
||||
S.drugRow('Calcium', '20 mg/kg CaCl or 60 mg/kg Ca-gluconate IV per unit citrated blood', 'IV', 'Citrated blood chelates calcium.')
|
||||
);
|
||||
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:16px 0 6px;">C-spine clearance (NEXUS / CCR)</h5>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;">Pediatric c-spine decision tools are imperfect. <strong>Imaging if any:</strong> focal neurologic deficit, altered mental status, neck pain / tenderness, torticollis, substantial torso injury, high-risk mechanism (diving, MVC >55 mph, fall >10 ft). Plain films + CT if positive or equivocal.</div>';
|
||||
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:16px 0 6px;">Pediatric shock — signs</h5>';
|
||||
html += '<div style="padding:10px;background:#fee2e2;border-radius:6px;font-size:12px;color:#991b1b;">Children compensate well — <strong>hypotension is a late finding</strong>. Early signs: tachycardia, cool extremities, weak peripheral pulses, prolonged cap refill (>3 sec), narrowed pulse pressure, altered mentation. Minimum SBP = 70 + (2 × age in years) for ages 1-10.</div>';
|
||||
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:16px 0 6px;">Secondary survey — AMPLE + head-to-toe</h5>';
|
||||
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>AMPLE:</strong> Allergies, Medications, Past history, Last meal, Events of injury. Head-to-toe exam; log-roll for back; digital rectal; neurovascular checks of all extremities.</div>';
|
||||
|
||||
html += S.ref('ATLS 10th ed; PALS 2020; PECARN c-spine rule; CRASH-2 trial (TXA).');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-trauma-show' || e.target.closest('#btn-trauma-show')) showTrauma();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
// ============================================================
|
||||
// bedside/ventilation.js
|
||||
// O2 & VENTILATION (teaching reference).
|
||||
// ============================================================
|
||||
|
||||
import { S } from './shared.js';
|
||||
|
||||
function showVent() {
|
||||
var wt = parseFloat(document.getElementById('vent-weight').value);
|
||||
var age = parseFloat(document.getElementById('vent-age').value);
|
||||
var el = document.getElementById('vent-result');
|
||||
var html = '';
|
||||
|
||||
// ── Target SpO2 quick reference ──
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:0 0 6px;">Target SpO2</h5>';
|
||||
html += '<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;margin-bottom:14px;"><table style="width:100%;min-width:460px;border-collapse:collapse;font-size:12px;">';
|
||||
html += '<thead><tr style="background:var(--g100);"><th style="text-align:left;padding:5px 8px;">Patient</th><th style="text-align:left;padding:5px 8px;">Target</th><th style="text-align:left;padding:5px 8px;">Notes</th></tr></thead><tbody>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Most children</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>94-98%</strong></td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Normal</td></tr>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Bronchiolitis (AAP 2014/2023)</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>≥90%</strong></td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Don\'t chase higher saturations</td></tr>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Chronic lung disease / CF</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>90-94%</strong></td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Avoid hyperoxia in CO2 retainers</td></tr>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Preterm neonate</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>90-95%</strong></td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Minimize ROP risk</td></tr>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Term neonate (min of life)</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>Per NRP ladder</strong></td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">1 min 60-65%, 10 min 85-95%</td></tr>';
|
||||
html += '</tbody></table></div>';
|
||||
|
||||
// ── Escalation ladder ──
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Escalation ladder (step up when target not reached / work of breathing)</h5>';
|
||||
html += S.stepBox('#10b981',
|
||||
'1. Nasal cannula (low-flow)',
|
||||
'<strong>0.5-6 L/min</strong> · FiO2 ~24-40% (rough: room air 21% + 4% per L/min) · comfortable, no humidification needed. Good for mild hypoxia or baseline support.');
|
||||
html += S.stepBox('#3b82f6',
|
||||
'2. Simple face mask',
|
||||
'<strong>6-10 L/min</strong> · FiO2 35-60%. Must keep flow >6 L/min to flush CO2. Tolerated less well than NC.');
|
||||
html += S.stepBox('#8b5cf6',
|
||||
'3. Non-rebreather mask',
|
||||
'<strong>10-15 L/min</strong> · FiO2 60-90%. Reservoir bag must stay inflated. For severe hypoxia with intact breathing.');
|
||||
html += S.stepBox('#f59e0b',
|
||||
'4. High-flow nasal cannula (HFNC)',
|
||||
'<strong>' + (wt ? '1-2 L/kg/min = ' + S.d(wt, 1) + '-' + S.d(wt, 2) + ' L/min' : '1-2 L/kg/min') + '</strong> (max ~50-60 L/min adults) · heated + humidified · FiO2 30-100% titratable · generates ~2-5 cmH2O PEEP · works for bronchiolitis, early resp failure. Reassess at 1-2 h — no improvement → step up.');
|
||||
html += S.stepBox('#ef4444',
|
||||
'5. Non-invasive ventilation (CPAP / BiPAP)',
|
||||
'<strong>CPAP</strong> 5-10 cmH2O (pure PEEP) · <strong>BiPAP</strong> IPAP 10-14 / EPAP 5 (adds pressure support) · FiO2 as needed. Needs cooperative patient, intact airway reflexes, no copious secretions. Contraindicated: coma, apnea, unstable hemodynamics, facial trauma.');
|
||||
html += S.stepBox('#dc2626',
|
||||
'6. Intubate + mechanical ventilation',
|
||||
'When NIV fails, airway compromised, apnea, or GCS ≤8. See RSI under the Airway tab for drugs.');
|
||||
|
||||
// ── BVM how-to ──
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Bag-Valve-Mask (BVM)</h5>';
|
||||
html += '<div style="padding:10px 12px;background:var(--blue-light);border-radius:6px;font-size:12px;line-height:1.6;">';
|
||||
html += '<strong>When:</strong> apnea, bradycardia (HR <60 in neonate; inadequate breathing at any age), during resuscitation.<br>';
|
||||
html += '<strong>Rate:</strong> Newborn 40-60 /min · Infant-child 20-30 /min · Adolescent 10-12 /min (1 breath q5-6 sec).<br>';
|
||||
html += '<strong>Tidal volume:</strong> 6-8 mL/kg — only enough to see <strong>gentle chest rise</strong>. Avoid over-ventilation (gastric distension, ↓venous return, lung injury).<br>';
|
||||
html += '<strong>Technique:</strong> head tilt / jaw thrust, proper mask seal (E-C grip or 2-thumb), squeeze 1 sec, release fully before next breath. Attach O2 at 10-15 L/min + reservoir.<br>';
|
||||
html += '<strong>If not ventilating:</strong> MR SOPA — <u>M</u>ask reseal, <u>R</u>eposition airway, <u>S</u>uction mouth+nose, <u>O</u>pen mouth, <u>P</u>ressure ↑, <u>A</u>lternative airway (LMA or ETT).';
|
||||
html += '</div>';
|
||||
|
||||
// ── Mechanical vent settings ──
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Mechanical ventilator — starting settings</h5>';
|
||||
var rate = age == null ? '' : (age < 0.1 ? '30-40' : age < 1 ? '25-35' : age < 5 ? '20-25' : age < 12 ? '16-20' : '12-16');
|
||||
html += '<div style="padding:10px 12px;background:var(--g50);border-radius:6px;font-size:12px;line-height:1.65;">';
|
||||
html += '<strong>Mode:</strong> Volume-control (set TV, get pressure) OR Pressure-control (set pressure, get TV). PRVC / SIMV-PS are hybrids. Pick what your unit uses.<br>';
|
||||
html += '<strong>Tidal volume:</strong> <strong>' + (wt ? S.d(wt, 6) + '-' + S.d(wt, 8) + ' mL' : '6-8 mL/kg') + '</strong> <span style="color:var(--g500);font-size:11px;">(6-8 mL/kg)</span>. Use lower (4-6 mL/kg) for ARDS.<br>';
|
||||
html += '<strong>Rate:</strong> ' + (rate ? rate + ' /min (for age ' + age + ' yr)' : 'Newborn 30-40 · Infant 25-35 · Child 16-20 · Adolescent 12-16') + '.<br>';
|
||||
html += '<strong>PEEP:</strong> start <strong>5 cmH2O</strong> (routine). Increase to 8-12+ for refractory hypoxia (ARDS).<br>';
|
||||
html += '<strong>FiO2:</strong> start 100%, wean rapidly to lowest that maintains target SpO2 (usually <60%).<br>';
|
||||
html += '<strong>I:E ratio:</strong> 1:2 normally. 1:3-4 for obstructive disease (asthma, bronchiolitis) to allow exhalation.<br>';
|
||||
html += '<strong>Inspiratory time:</strong> 0.5 s infant · 0.7-1 s child · 1-1.2 s adolescent.<br>';
|
||||
html += '<strong>Pressure limits:</strong> keep plateau <30 cmH2O (ideally <28) to avoid barotrauma.';
|
||||
html += '</div>';
|
||||
|
||||
// ── Pressure-time waveform ──
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Pressure-time waveform (what the monitor shows)</h5>';
|
||||
html += '<div style="padding:10px;background:white;border:1px solid var(--g200);border-radius:8px;margin-bottom:14px;">';
|
||||
html += '<svg viewBox="0 0 540 260" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:640px;display:block;margin:0 auto;">' +
|
||||
// grid lines
|
||||
'<line x1="60" y1="60" x2="500" y2="60" stroke="#f3f4f6"/>' +
|
||||
'<line x1="60" y1="100" x2="500" y2="100" stroke="#f3f4f6"/>' +
|
||||
'<line x1="60" y1="140" x2="500" y2="140" stroke="#f3f4f6"/>' +
|
||||
'<line x1="60" y1="180" x2="500" y2="180" stroke="#f3f4f6"/>' +
|
||||
// axes
|
||||
'<line x1="60" y1="220" x2="500" y2="220" stroke="#374151" stroke-width="1.5"/>' +
|
||||
'<line x1="60" y1="20" x2="60" y2="220" stroke="#374151" stroke-width="1.5"/>' +
|
||||
// y-axis tick labels (cmH2O)
|
||||
'<text x="55" y="64" font-size="10" text-anchor="end" fill="#6b7280">30</text>' +
|
||||
'<text x="55" y="104" font-size="10" text-anchor="end" fill="#6b7280">20</text>' +
|
||||
'<text x="55" y="144" font-size="10" text-anchor="end" fill="#6b7280">10</text>' +
|
||||
'<text x="55" y="184" font-size="10" text-anchor="end" fill="#6b7280">5</text>' +
|
||||
'<text x="55" y="224" font-size="10" text-anchor="end" fill="#6b7280">0</text>' +
|
||||
// axis titles
|
||||
'<text x="275" y="250" font-size="11" text-anchor="middle" fill="#374151">Time (seconds)</text>' +
|
||||
'<text x="18" y="120" font-size="11" text-anchor="middle" fill="#374151" transform="rotate(-90 18 120)">Pressure (cmH₂O)</text>' +
|
||||
// x-axis time markers
|
||||
'<text x="60" y="238" font-size="10" text-anchor="middle" fill="#6b7280">0</text>' +
|
||||
'<text x="200" y="238" font-size="10" text-anchor="middle" fill="#6b7280">2</text>' +
|
||||
'<text x="340" y="238" font-size="10" text-anchor="middle" fill="#6b7280">4</text>' +
|
||||
'<text x="480" y="238" font-size="10" text-anchor="middle" fill="#6b7280">6</text>' +
|
||||
// PEEP reference dashed line (y=180 = 5 cmH2O)
|
||||
'<line x1="60" y1="180" x2="500" y2="180" stroke="#10b981" stroke-width="1" stroke-dasharray="4,3" opacity="0.5"/>' +
|
||||
// Pressure curve — 3 breaths, each 2s (Ti=0.8s, Te=1.2s); 70px/sec; PEEP=5→y=180, PIP=25→y=80, Plateau=22→y=88
|
||||
'<path d="M 60 180 L 80 180 L 90 80 L 145 88 L 155 180 L 200 180 L 210 80 L 265 88 L 275 180 L 340 180 L 350 80 L 405 88 L 415 180 L 480 180" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linejoin="round"/>' +
|
||||
// PIP label
|
||||
'<line x1="120" y1="50" x2="92" y2="78" stroke="#ef4444"/>' +
|
||||
'<text x="125" y="48" font-size="11" font-weight="bold" fill="#ef4444">PIP</text>' +
|
||||
'<text x="125" y="60" font-size="9" fill="#6b7280">peak inspiratory</text>' +
|
||||
// Plateau label
|
||||
'<line x1="230" y1="50" x2="145" y2="88" stroke="#a855f7"/>' +
|
||||
'<text x="235" y="48" font-size="11" font-weight="bold" fill="#a855f7">Plateau</text>' +
|
||||
'<text x="235" y="60" font-size="9" fill="#6b7280">reflects alveolar pressure</text>' +
|
||||
// PEEP label
|
||||
'<line x1="380" y1="208" x2="345" y2="182" stroke="#10b981"/>' +
|
||||
'<text x="385" y="208" font-size="11" font-weight="bold" fill="#10b981">PEEP</text>' +
|
||||
// Ti bracket (x=90 to 155)
|
||||
'<line x1="90" y1="32" x2="155" y2="32" stroke="#f59e0b" stroke-width="1"/>' +
|
||||
'<line x1="90" y1="28" x2="90" y2="36" stroke="#f59e0b"/>' +
|
||||
'<line x1="155" y1="28" x2="155" y2="36" stroke="#f59e0b"/>' +
|
||||
'<text x="122" y="25" font-size="10" font-weight="bold" text-anchor="middle" fill="#f59e0b">Ti</text>' +
|
||||
// Te bracket (x=155 to 200)
|
||||
'<line x1="155" y1="202" x2="200" y2="202" stroke="#f59e0b" stroke-width="1"/>' +
|
||||
'<line x1="155" y1="198" x2="155" y2="206" stroke="#f59e0b"/>' +
|
||||
'<line x1="200" y1="198" x2="200" y2="206" stroke="#f59e0b"/>' +
|
||||
'<text x="177" y="215" font-size="10" font-weight="bold" text-anchor="middle" fill="#f59e0b">Te</text>' +
|
||||
'</svg>';
|
||||
html += '<div style="font-size:11px;color:var(--g500);margin-top:8px;line-height:1.5;"><strong style="color:#ef4444;">PIP</strong> = peak pressure during inspiration (depends on TV, airway resistance, lung compliance). <strong style="color:#a855f7;">Plateau</strong> = static pressure at end-inspiration with brief breath-hold (reflects alveolar pressure — keep <30). <strong style="color:#10b981;">PEEP</strong> = baseline end-expiratory pressure keeping alveoli open. <strong style="color:#f59e0b;">Ti / Te</strong> = inspiratory / expiratory time. Rate × (Ti+Te) = 60 s.</div>';
|
||||
html += '</div>';
|
||||
|
||||
// ── Troubleshooting: "if this, change that" ──
|
||||
html += '<h5 style="font-size:13px;font-weight:700;color:var(--g700);margin:14px 0 6px;">Adjusting for gas exchange</h5>';
|
||||
html += '<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;"><table style="width:100%;min-width:480px;border-collapse:collapse;font-size:12px;">';
|
||||
html += '<thead><tr style="background:var(--g100);"><th style="text-align:left;padding:5px 8px;">Problem</th><th style="text-align:left;padding:5px 8px;">First lever</th><th style="text-align:left;padding:5px 8px;">Second lever</th></tr></thead><tbody>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>Low SpO2</strong> (oxygenation)</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">↑ FiO2</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">↑ PEEP (recruits collapsed alveoli)</td></tr>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>↑ PCO2</strong> (ventilation)</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">↑ Rate</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">↑ Tidal volume</td></tr>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>↓ PCO2</strong> (over-ventilating)</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">↓ Rate</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">↓ Tidal volume</td></tr>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>High peak pressure</strong></td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Check tube (kink, mucus plug), compliance, bronchospasm</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Suction, bronchodilator, lower TV</td></tr>';
|
||||
html += '<tr><td style="padding:5px 8px;border-bottom:1px solid var(--g100);"><strong>Auto-PEEP</strong> (asthma, bronchiolitis)</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">↓ Rate, ↑ expiratory time</td><td style="padding:5px 8px;border-bottom:1px solid var(--g100);">Disconnect + bag briefly if critical</td></tr>';
|
||||
html += '</tbody></table></div>';
|
||||
|
||||
// ── Mental model summary ──
|
||||
html += '<div style="margin-top:14px;padding:10px 12px;background:#d1fae5;border-radius:6px;font-size:12px;color:#065f46;line-height:1.55;"><strong>Mental model:</strong> Oxygenation is mostly <strong>FiO2 + PEEP</strong>. Ventilation (CO2) is mostly <strong>rate + tidal volume</strong>. Match mode to disease: obstructive (asthma, bronchiolitis) → long expiratory time, permissive hypercapnia. Restrictive (ARDS) → low TV, high PEEP, permissive hypercapnia + hypoxia.</div>';
|
||||
|
||||
html += S.ref('Based on AAP / PALS / AARC guidance. Tailor to individual patient and institutional protocols.');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'btn-vent-show' || e.target.closest('#btn-vent-show')) showVent();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
// ============================================================
|
||||
// BROWSER WHISPER — client-side transcription, zero server calls
|
||||
// Uses @xenova/transformers running Whisper in WebAssembly.
|
||||
// Audio is processed entirely in-browser, never transmitted.
|
||||
//
|
||||
// Models (cached in IndexedDB after first download):
|
||||
// Xenova/whisper-tiny.en — ~39MB — fastest, ~2-3s/clip
|
||||
// Xenova/whisper-base.en — ~74MB — balanced, ~3-5s/clip
|
||||
// Xenova/whisper-small.en — ~244MB — best quality, ~6-10s/clip
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
var STORAGE_ENABLED = 'ped_browser_whisper';
|
||||
var STORAGE_MODEL = 'ped_whisper_model';
|
||||
var DEFAULT_MODEL = 'Xenova/whisper-tiny.en';
|
||||
|
||||
var _worker = null;
|
||||
var _ready = false;
|
||||
var _loading = false;
|
||||
var _modelLoaded = null;
|
||||
var _pending = null; // { resolve, reject }
|
||||
|
||||
window.BrowserWhisper = {
|
||||
|
||||
isSupported: function() {
|
||||
return typeof Worker !== 'undefined' &&
|
||||
typeof AudioContext !== 'undefined' &&
|
||||
typeof WebAssembly !== 'undefined';
|
||||
},
|
||||
|
||||
isEnabled: function() {
|
||||
try { return localStorage.getItem(STORAGE_ENABLED) === '1'; } catch(e) { return false; }
|
||||
},
|
||||
|
||||
setEnabled: function(val) {
|
||||
try { localStorage.setItem(STORAGE_ENABLED, val ? '1' : '0'); } catch(e) {}
|
||||
},
|
||||
|
||||
getModel: function() {
|
||||
try { return localStorage.getItem(STORAGE_MODEL) || DEFAULT_MODEL; } catch(e) { return DEFAULT_MODEL; }
|
||||
},
|
||||
|
||||
setModel: function(m) {
|
||||
try { localStorage.setItem(STORAGE_MODEL, m); } catch(e) {}
|
||||
// Force reload next time
|
||||
_ready = false;
|
||||
_modelLoaded = null;
|
||||
},
|
||||
|
||||
// Pre-warm: load model in background before first recording
|
||||
preload: function(onProgress) {
|
||||
if (!this.isSupported() || !this.isEnabled()) return;
|
||||
_initWorker(this.getModel(), onProgress || function() {});
|
||||
},
|
||||
|
||||
// Transcribe a Blob (WebM, WAV, etc.)
|
||||
transcribe: function(blob) {
|
||||
var self = this;
|
||||
if (!this.isSupported()) return Promise.reject(new Error('WebAssembly not supported'));
|
||||
if (!this.isEnabled()) return Promise.reject(new Error('Browser Whisper not enabled'));
|
||||
|
||||
return _blobToFloat32(blob).then(function(float32) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var model = self.getModel();
|
||||
|
||||
// If worker is ready with same model, send immediately
|
||||
if (_ready && _modelLoaded === model) {
|
||||
_pending = { resolve: resolve, reject: reject };
|
||||
_worker.postMessage({ type: 'transcribe', audio: float32, model: model }, [float32.buffer]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to (re)load model first
|
||||
_pending = { resolve: resolve, reject: reject };
|
||||
_initWorker(model, function() {}, function() {
|
||||
_worker.postMessage({ type: 'transcribe', audio: float32, model: model }, [float32.buffer]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────
|
||||
|
||||
function _initWorker(model, onProgress, onReady) {
|
||||
if (_loading && _modelLoaded === model) return; // already loading same model
|
||||
|
||||
if (_worker) { _worker.terminate(); _worker = null; }
|
||||
_ready = false;
|
||||
_loading = true;
|
||||
_modelLoaded = model;
|
||||
|
||||
_worker = new Worker('/js/whisperWorker.js');
|
||||
|
||||
_worker.addEventListener('message', function(e) {
|
||||
var d = e.data;
|
||||
|
||||
if (d.type === 'loading') {
|
||||
if (onProgress) onProgress('Downloading Whisper model (' + d.model.split('/').pop() + ')...', 0);
|
||||
}
|
||||
if (d.type === 'progress') {
|
||||
if (onProgress) onProgress(d.file.split('/').pop() || 'model', d.progress);
|
||||
}
|
||||
if (d.type === 'ready') {
|
||||
_ready = true;
|
||||
_loading = false;
|
||||
if (onProgress) onProgress('', 100);
|
||||
if (onReady) onReady();
|
||||
}
|
||||
if (d.type === 'result') {
|
||||
if (_pending) { _pending.resolve(d.text); _pending = null; }
|
||||
}
|
||||
if (d.type === 'error') {
|
||||
_loading = false;
|
||||
_ready = false;
|
||||
console.error('[BrowserWhisper] Worker error:', d.message);
|
||||
// Show user-friendly message
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('Browser Whisper blocked by network/firewall. Using server transcription.', 'warning');
|
||||
}
|
||||
if (_pending) { _pending.reject(new Error(d.message)); _pending = null; }
|
||||
if (onProgress) onProgress('', 0);
|
||||
}
|
||||
});
|
||||
|
||||
_worker.addEventListener('error', function(err) {
|
||||
_loading = false;
|
||||
_ready = false;
|
||||
console.error('[BrowserWhisper] Worker crashed:', err);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('Browser Whisper unavailable. Using server transcription.', 'warning');
|
||||
}
|
||||
if (_pending) { _pending.reject(err); _pending = null; }
|
||||
if (onProgress) onProgress('', 0);
|
||||
});
|
||||
|
||||
_worker.postMessage({ type: 'load', model: model });
|
||||
}
|
||||
|
||||
// Convert audio Blob → Float32Array at 16kHz mono (what Whisper expects)
|
||||
function _blobToFloat32(blob) {
|
||||
return blob.arrayBuffer().then(function(buf) {
|
||||
var ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
return ctx.decodeAudioData(buf).then(function(audioBuffer) {
|
||||
ctx.close();
|
||||
return audioBuffer.getChannelData(0); // mono
|
||||
}).catch(function(err) {
|
||||
ctx.close();
|
||||
throw new Error('Audio decode failed: ' + err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
// ============================================================
|
||||
// calc-math.js — Pure, side-effect-free dose / score math.
|
||||
// Dual-exported: browser (window._PED_MATH) + Node (module.exports).
|
||||
// Keep this file free of DOM references so it can be unit-tested
|
||||
// under Node with `node --test test/`.
|
||||
// ============================================================
|
||||
(function() {
|
||||
function roundTo(v, places) { var m = Math.pow(10, places); return Math.round(v * m) / m; }
|
||||
|
||||
// ── Age → Weight ────────────────────────────────────────────
|
||||
// APLS (Luscombe & Owens 2007 / APLS-UK 2016) and Best Guess
|
||||
// (Tinning & Acworth 2007). For ages ≥13 yr we use Best Guess
|
||||
// as primary; otherwise APLS.
|
||||
function estimateWeightFromAgeMonths(months) {
|
||||
if (months == null || isNaN(months) || months < 0) return null;
|
||||
var years = months / 12;
|
||||
var apls;
|
||||
if (months < 12) apls = 0.5 * months + 4;
|
||||
else if (years <= 5) apls = 2 * years + 8;
|
||||
else apls = 3 * years + 7;
|
||||
var bg;
|
||||
if (months < 12) bg = (months + 9) / 2;
|
||||
else if (years <= 5) bg = 2 * (years + 5);
|
||||
else bg = 4 * years;
|
||||
var primary = years < 13 ? apls : bg;
|
||||
var bandLabel;
|
||||
if (years < 13) {
|
||||
if (months < 12) bandLabel = 'APLS 0-12 mo';
|
||||
else if (years <= 5) bandLabel = 'APLS 1-5 yr';
|
||||
else bandLabel = 'APLS 6-12 yr';
|
||||
} else {
|
||||
bandLabel = 'Best Guess 5-14 yr';
|
||||
}
|
||||
return {
|
||||
weight: Math.max(0.3, roundTo(primary, 1)),
|
||||
formulaLabel: bandLabel,
|
||||
all: { apls: roundTo(apls, 1), bestGuess: roundTo(bg, 1) }
|
||||
};
|
||||
}
|
||||
|
||||
// ── Parkland fluid resuscitation (burn) ─────────────────────
|
||||
// 4 mL × weight(kg) × %TBSA over 24 h; half in first 8 h.
|
||||
function parkland(weightKg, tbsaPct) {
|
||||
if (!weightKg || weightKg <= 0 || !tbsaPct || tbsaPct <= 0) return null;
|
||||
var total = 4 * weightKg * tbsaPct;
|
||||
var first8 = total / 2;
|
||||
var next16 = total - first8;
|
||||
return {
|
||||
totalMl: Math.round(total),
|
||||
first8Ml: Math.round(first8),
|
||||
next16Ml: Math.round(next16),
|
||||
rateFirst8: Math.round(first8 / 8),
|
||||
rateNext16: Math.round(next16 / 16)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Maintenance fluids (Holliday-Segar 4-2-1 rule) ─────────
|
||||
function maintenance421(weightKg) {
|
||||
if (!weightKg || weightKg <= 0) return null;
|
||||
if (weightKg <= 10) return Math.round(weightKg * 4);
|
||||
if (weightKg <= 20) return Math.round(40 + (weightKg - 10) * 2);
|
||||
return Math.round(60 + (weightKg - 20));
|
||||
}
|
||||
|
||||
// ── PRAM score (asthma, 0-12) ───────────────────────────────
|
||||
function pramScore(spo2, retractions, scalene, airEntry, wheeze) {
|
||||
var total = [spo2, retractions, scalene, airEntry, wheeze]
|
||||
.reduce(function(s, v) { return s + (parseInt(v, 10) || 0); }, 0);
|
||||
var severity = total <= 3 ? 'Mild' : total <= 7 ? 'Moderate' : 'Severe';
|
||||
return { total: total, severity: severity };
|
||||
}
|
||||
|
||||
// ── Westley croup score (0-17) ──────────────────────────────
|
||||
function westleyScore(conscious, cyanosis, stridor, airEntry, retractions) {
|
||||
var total = [conscious, cyanosis, stridor, airEntry, retractions]
|
||||
.reduce(function(s, v) { return s + (parseInt(v, 10) || 0); }, 0);
|
||||
var severity;
|
||||
if (total <= 2) severity = 'Mild';
|
||||
else if (total <= 5) severity = 'Moderate';
|
||||
else if (total <= 11) severity = 'Severe';
|
||||
else severity = 'Impending respiratory failure';
|
||||
return { total: total, severity: severity };
|
||||
}
|
||||
|
||||
// ── Epinephrine: anaphylaxis IM vs arrest IV ────────────────
|
||||
// IMPORTANT: different concentrations (1:1,000 vs 1:10,000).
|
||||
function epiAnaphylaxisIM(weightKg) {
|
||||
if (!weightKg || weightKg <= 0) return null;
|
||||
var mg = Math.min(weightKg * 0.01, 0.5);
|
||||
return { doseMg: roundTo(mg, 2), volumeMl_1in1000: roundTo(mg, 2) };
|
||||
}
|
||||
function epiArrestIV(weightKg) {
|
||||
if (!weightKg || weightKg <= 0) return null;
|
||||
var mg = Math.min(weightKg * 0.01, 1);
|
||||
return { doseMg: roundTo(mg, 2), volumeMl_1in10000: roundTo(mg * 10, 2) };
|
||||
}
|
||||
|
||||
// ── NRP epinephrine (neonatal resus) ────────────────────────
|
||||
// IV/IO 0.01-0.03 mg/kg = 0.1-0.3 mL/kg of 1:10,000.
|
||||
function nrpEpiIV(weightKg) {
|
||||
if (!weightKg || weightKg <= 0) return null;
|
||||
return {
|
||||
lowMg: roundTo(weightKg * 0.01, 2),
|
||||
highMg: roundTo(weightKg * 0.03, 2),
|
||||
lowMl: roundTo(weightKg * 0.1, 2),
|
||||
highMl: roundTo(weightKg * 0.3, 2)
|
||||
};
|
||||
}
|
||||
|
||||
// ── RSI drug doses (selected) ───────────────────────────────
|
||||
function rsiKetamine(weightKg) {
|
||||
if (!weightKg) return null;
|
||||
return { lowMg: Math.min(roundTo(weightKg * 1.5, 1), 150), highMg: Math.min(roundTo(weightKg * 2, 1), 200) };
|
||||
}
|
||||
function rsiRocuronium(weightKg) {
|
||||
if (!weightKg) return null;
|
||||
return { mg: Math.min(roundTo(weightKg * 1.2, 1), 100) };
|
||||
}
|
||||
function rsiSuccinylcholine(weightKg) {
|
||||
if (!weightKg) return null;
|
||||
var perKg = weightKg < 10 ? 2 : 1.5;
|
||||
return { mg: Math.min(roundTo(weightKg * perKg, 1), 150), perKg: perKg };
|
||||
}
|
||||
|
||||
// ── Minimum acceptable systolic BP (1-10 yr) ────────────────
|
||||
function minSBP(years) {
|
||||
if (years == null || isNaN(years)) return null;
|
||||
if (years < 1) return 70;
|
||||
if (years > 10) return 90;
|
||||
return 70 + 2 * years;
|
||||
}
|
||||
|
||||
// ── ETT (endotracheal tube) sizing by age ───────────────────
|
||||
function ettSize(ageYears) {
|
||||
if (ageYears == null || ageYears < 0) return null;
|
||||
var uncuffed = ageYears / 4 + 4;
|
||||
var cuffed = ageYears / 4 + 3.5;
|
||||
return {
|
||||
uncuffedMm: roundTo(uncuffed, 1),
|
||||
cuffedMm: roundTo(cuffed, 1),
|
||||
depthCm: roundTo(uncuffed * 3, 1)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Lund-Browder regional TBSA by age bracket ───────────────
|
||||
// Used for burns TBSA calculation. Bracket: 'infant', 'young',
|
||||
// 'child', 'adol', 'adult' — per 0-1, 1-5, 5-10, 10-15, >15 yr.
|
||||
var LB_BRACKETS = ['infant', 'young', 'child', 'adol', 'adult'];
|
||||
var LB = {
|
||||
head: [18, 13, 11, 9, 7],
|
||||
neck: [2, 2, 2, 2, 2],
|
||||
ant_trunk: [13, 13, 13, 13, 13],
|
||||
post_trunk: [13, 13, 13, 13, 13],
|
||||
r_buttock: [2.5, 2.5, 2.5, 2.5, 2.5],
|
||||
l_buttock: [2.5, 2.5, 2.5, 2.5, 2.5],
|
||||
genital: [1, 1, 1, 1, 1],
|
||||
r_uparm: [4, 4, 4, 4, 4],
|
||||
l_uparm: [4, 4, 4, 4, 4],
|
||||
r_forearm: [3, 3, 3, 3, 3],
|
||||
l_forearm: [3, 3, 3, 3, 3],
|
||||
r_hand: [2.5, 2.5, 2.5, 2.5, 2.5],
|
||||
l_hand: [2.5, 2.5, 2.5, 2.5, 2.5],
|
||||
r_thigh: [5.5, 8, 8.5, 9, 9.5],
|
||||
l_thigh: [5.5, 8, 8.5, 9, 9.5],
|
||||
r_leg: [5, 5.5, 6, 6.5, 7],
|
||||
l_leg: [5, 5.5, 6, 6.5, 7],
|
||||
r_foot: [3.5, 3.5, 3.5, 3.5, 3.5],
|
||||
l_foot: [3.5, 3.5, 3.5, 3.5, 3.5]
|
||||
};
|
||||
|
||||
// involvements: { regionKey: percentOfRegionInvolved (0-100), ... }
|
||||
function lundBrowderTBSA(ageBracket, involvements) {
|
||||
var idx = LB_BRACKETS.indexOf(ageBracket);
|
||||
if (idx < 0) return null;
|
||||
var total = 0;
|
||||
Object.keys(LB).forEach(function(key) {
|
||||
var pct = (involvements && involvements[key]) || 0;
|
||||
if (pct < 0) pct = 0; if (pct > 100) pct = 100;
|
||||
total += LB[key][idx] * (pct / 100);
|
||||
});
|
||||
return roundTo(total, 1);
|
||||
}
|
||||
|
||||
var API = {
|
||||
roundTo: roundTo,
|
||||
estimateWeightFromAgeMonths: estimateWeightFromAgeMonths,
|
||||
parkland: parkland,
|
||||
maintenance421: maintenance421,
|
||||
pramScore: pramScore,
|
||||
westleyScore: westleyScore,
|
||||
epiAnaphylaxisIM: epiAnaphylaxisIM,
|
||||
epiArrestIV: epiArrestIV,
|
||||
nrpEpiIV: nrpEpiIV,
|
||||
rsiKetamine: rsiKetamine,
|
||||
rsiRocuronium: rsiRocuronium,
|
||||
rsiSuccinylcholine: rsiSuccinylcholine,
|
||||
minSBP: minSBP,
|
||||
ettSize: ettSize,
|
||||
lundBrowderTBSA: lundBrowderTBSA,
|
||||
LB_BRACKETS: LB_BRACKETS,
|
||||
LB: LB
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') window._PED_MATH = API;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = API;
|
||||
})();
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,192 +0,0 @@
|
|||
(function() {
|
||||
var _inited = false;
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'chart' || _inited) return;
|
||||
_inited = true;
|
||||
var visitsContainer = document.getElementById('cr-visits-container');
|
||||
var addVisitBtn = document.getElementById('cr-add-visit');
|
||||
var labsContainer = document.getElementById('cr-labs-container');
|
||||
var addLabBtn = document.getElementById('cr-add-lab');
|
||||
var generateBtn = document.getElementById('cr-generate-btn');
|
||||
var outputCard = document.getElementById('cr-output');
|
||||
var reviewText = document.getElementById('cr-review-text');
|
||||
var modelTag = document.getElementById('cr-model-tag');
|
||||
|
||||
var visitIndex = 1;
|
||||
|
||||
function resetChartReview() {
|
||||
// Clear demographics and fields
|
||||
['cr-age', 'cr-pmh', 'cr-instructions', 'chart-label'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
var gender = document.getElementById('cr-gender');
|
||||
if (gender) gender.value = '';
|
||||
var type = document.getElementById('cr-type');
|
||||
if (type) type.value = 'outpatient';
|
||||
|
||||
// Reset visit index and restore single empty visit card
|
||||
visitIndex = 1;
|
||||
visitsContainer.innerHTML = '<div class="card visit-card">' +
|
||||
'<div class="card-header"><h3><i class="fas fa-calendar"></i> Visit / Note #1</h3>' +
|
||||
'<button class="btn-sm btn-ghost remove-visit-btn"><i class="fas fa-trash"></i></button></div>' +
|
||||
'<div class="note-entry"><div class="note-meta">' +
|
||||
'<input type="date" class="visit-date">' +
|
||||
'<select class="visit-type"><option value="outpatient">Outpatient Visit</option><option value="subspecialty">Subspecialty Note</option><option value="ed">ED Visit</option></select>' +
|
||||
'<input type="text" class="visit-specialist" placeholder="Specialist name (if subspecialty)">' +
|
||||
'<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)"></div>' +
|
||||
'<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>' +
|
||||
'<textarea class="visit-labs" placeholder="Labs from this visit (optional)" rows="3"></textarea></div></div>';
|
||||
visitsContainer.querySelector('.remove-visit-btn').addEventListener('click', function() {
|
||||
visitsContainer.querySelector('.visit-card').remove();
|
||||
});
|
||||
|
||||
// Clear labs container - restore single empty entry
|
||||
labsContainer.innerHTML = '<div class="lab-entry">' +
|
||||
'<input type="date" class="lab-date">' +
|
||||
'<textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>' +
|
||||
'</div>';
|
||||
|
||||
// Clear output
|
||||
reviewText.textContent = '';
|
||||
outputCard.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Expose for encounters.js clearTab
|
||||
window.resetChartReview = resetChartReview;
|
||||
|
||||
addVisitBtn.addEventListener('click', function() {
|
||||
visitIndex++;
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card visit-card';
|
||||
card.innerHTML = '<div class="card-header"><h3><i class="fas fa-calendar"></i> Visit / Note #' + visitIndex + '</h3>' +
|
||||
'<button class="btn-sm btn-ghost remove-visit-btn"><i class="fas fa-trash"></i></button></div>' +
|
||||
'<div class="note-entry"><div class="note-meta">' +
|
||||
'<input type="date" class="visit-date">' +
|
||||
'<select class="visit-type"><option value="outpatient">Outpatient Visit</option><option value="subspecialty">Subspecialty Note</option><option value="ed">ED Visit</option></select>' +
|
||||
'<input type="text" class="visit-specialist" placeholder="Specialist name">' +
|
||||
'<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)"></div>' +
|
||||
'<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>' +
|
||||
'<textarea class="visit-labs" placeholder="Labs from this visit (optional)" rows="3"></textarea></div>';
|
||||
visitsContainer.appendChild(card);
|
||||
card.querySelector('.remove-visit-btn').addEventListener('click', function() { card.remove(); });
|
||||
});
|
||||
|
||||
document.querySelectorAll('.remove-visit-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { btn.closest('.visit-card').remove(); });
|
||||
});
|
||||
|
||||
addLabBtn.addEventListener('click', function() {
|
||||
var entry = document.createElement('div');
|
||||
entry.className = 'lab-entry';
|
||||
entry.innerHTML = '<input type="date" class="lab-date"><textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>' +
|
||||
'<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>';
|
||||
labsContainer.appendChild(entry);
|
||||
});
|
||||
|
||||
generateBtn.addEventListener('click', function() {
|
||||
var type = document.getElementById('cr-type').value;
|
||||
var visits = [];
|
||||
var subspecialty = [];
|
||||
var edVisits = [];
|
||||
|
||||
visitsContainer.querySelectorAll('.visit-card').forEach(function(card) {
|
||||
var content = card.querySelector('.visit-content').innerText.trim();
|
||||
if (!content) return;
|
||||
var vType = card.querySelector('.visit-type').value;
|
||||
var entry = {
|
||||
date: card.querySelector('.visit-date').value || '',
|
||||
content: content,
|
||||
labs: card.querySelector('.visit-labs').value || ''
|
||||
};
|
||||
|
||||
if (vType === 'subspecialty') {
|
||||
entry.specialistName = card.querySelector('.visit-specialist').value || '';
|
||||
entry.specialty = card.querySelector('.visit-specialty').value || '';
|
||||
subspecialty.push(entry);
|
||||
} else if (vType === 'ed') {
|
||||
edVisits.push(entry);
|
||||
} else {
|
||||
entry.type = 'outpatient';
|
||||
visits.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
var labs = [];
|
||||
labsContainer.querySelectorAll('.lab-entry').forEach(function(entry) {
|
||||
var vals = entry.querySelector('.lab-values').value.trim();
|
||||
if (vals) labs.push({ date: entry.querySelector('.lab-date').value || '', values: vals });
|
||||
});
|
||||
|
||||
if (visits.length === 0 && subspecialty.length === 0 && edVisits.length === 0) {
|
||||
showToast('Add at least one visit/note', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if payload is very large (>8MB = approaching 10MB server limit)
|
||||
var payloadEstimate = JSON.stringify({ visits: visits, subspecialty: subspecialty, edVisits: edVisits, labs: labs }).length;
|
||||
if (payloadEstimate > 8 * 1024 * 1024) {
|
||||
showToast('Notes are very large (' + Math.round(payloadEstimate / 1024 / 1024) + 'MB). Consider trimming some notes.', 'warning');
|
||||
return;
|
||||
}
|
||||
var totalNotes = visits.length + subspecialty.length + edVisits.length;
|
||||
if (totalNotes > 30) {
|
||||
showToast('Processing ' + totalNotes + ' notes — this may take a moment...', 'info');
|
||||
}
|
||||
|
||||
showBusy('Generating chart review...');
|
||||
|
||||
fetch('/api/generate-chart-review', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
type: type,
|
||||
visits: visits,
|
||||
subspecialty: subspecialty,
|
||||
edVisits: edVisits,
|
||||
labs: labs,
|
||||
patientAge: document.getElementById('cr-age').value,
|
||||
patientGender: document.getElementById('cr-gender').value,
|
||||
pmh: document.getElementById('cr-pmh').value,
|
||||
additionalInstructions: document.getElementById('cr-instructions').value,
|
||||
model: getSelectedModel()
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
setOutputText(reviewText, data.review);
|
||||
// Store source material for refine
|
||||
var sourceItems = [];
|
||||
visits.forEach(function(v) { sourceItems.push('VISIT (' + v.date + '): ' + v.content + (v.labs ? '\nLabs: ' + v.labs : '')); });
|
||||
subspecialty.forEach(function(v) { sourceItems.push('SUBSPECIALTY (' + v.date + ', ' + v.specialty + '): ' + v.content + (v.labs ? '\nLabs: ' + v.labs : '')); });
|
||||
edVisits.forEach(function(v) { sourceItems.push('ED VISIT (' + v.date + '): ' + v.content + (v.labs ? '\nLabs: ' + v.labs : '')); });
|
||||
labs.forEach(function(l) { sourceItems.push('LAB (' + l.date + '): ' + l.values); });
|
||||
storeSourceContext('cr-review-text', sourceItems.join('\n\n'));
|
||||
modelTag.textContent = (data.model || '').split('/').pop();
|
||||
outputCard.classList.remove('hidden');
|
||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||
showToast('Chart review generated!', 'success');
|
||||
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('cr-review-text', data.review, 'chart', document.getElementById('cr-age').value);
|
||||
} else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
document.getElementById('cr-refine-btn').addEventListener('click', function() { refineDocument('cr-review-text', 'cr-refine-input'); });
|
||||
document.getElementById('cr-shorten-btn').addEventListener('click', function() { shortenDocument('cr-review-text'); });
|
||||
|
||||
// Register load handler for saved encounters
|
||||
window.registerEncounterLoadHandler('chart', function(enc) {
|
||||
if (enc.generated_note) {
|
||||
var reviewText = document.getElementById('cr-review-text');
|
||||
var outputCard = document.getElementById('cr-output');
|
||||
if (reviewText) reviewText.textContent = enc.generated_note;
|
||||
if (outputCard) outputCard.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Chart Review module loaded');
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
// ============================================================
|
||||
// CORRECTION TRACKER — Dragon-like AI learning from user edits
|
||||
// Tracks when users edit AI-generated output and saves corrections
|
||||
// so the AI can learn user preferences over time.
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// Store original AI outputs per element
|
||||
var _originals = {};
|
||||
|
||||
// Track an output element: store original text when AI generates it
|
||||
window.trackAIOutput = function(elementId, originalText) {
|
||||
if (!elementId || !originalText) return;
|
||||
_originals[elementId] = originalText.trim();
|
||||
};
|
||||
|
||||
// Save correction when user is done editing (call on save or blur)
|
||||
window.saveCorrection = function(elementId, section) {
|
||||
var original = _originals[elementId];
|
||||
if (!original) return;
|
||||
var el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
var current = (el.innerText || el.textContent || '').trim();
|
||||
if (!current || current === original) return;
|
||||
|
||||
// Only save if there's a meaningful difference (not just whitespace)
|
||||
var origWords = original.split(/\s+/).length;
|
||||
var currWords = current.split(/\s+/).length;
|
||||
var wordDiff = Math.abs(origWords - currWords);
|
||||
// Require at least some meaningful change
|
||||
if (wordDiff < 2 && original.length > 100) {
|
||||
// Check character-level difference
|
||||
var charDiff = Math.abs(original.length - current.length);
|
||||
if (charDiff < 20) return; // too minor
|
||||
}
|
||||
|
||||
// Find the most significant changed section (not full text)
|
||||
var origSnippet = extractDiffSnippet(original, current);
|
||||
var corrSnippet = extractDiffSnippet(current, original);
|
||||
|
||||
if (!origSnippet || !corrSnippet) {
|
||||
origSnippet = original.substring(0, 500);
|
||||
corrSnippet = current.substring(0, 500);
|
||||
}
|
||||
|
||||
fetch('/api/memories/correction', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
section: section || 'encounter',
|
||||
original_snippet: origSnippet,
|
||||
corrected_snippet: corrSnippet
|
||||
})
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success && !data.skipped) {
|
||||
console.log('[CorrectionTracker] Saved correction for', section);
|
||||
}
|
||||
}).catch(function() {});
|
||||
|
||||
// Clear so we don't re-save
|
||||
delete _originals[elementId];
|
||||
};
|
||||
|
||||
// Extract the most changed portion between two texts
|
||||
function extractDiffSnippet(text1, text2) {
|
||||
var lines1 = text1.split('\n');
|
||||
var lines2 = text2.split('\n');
|
||||
var changed = [];
|
||||
var maxLen = Math.max(lines1.length, lines2.length);
|
||||
|
||||
for (var i = 0; i < maxLen; i++) {
|
||||
var l1 = (lines1[i] || '').trim();
|
||||
var l2 = (lines2[i] || '').trim();
|
||||
if (l1 !== l2 && l1) {
|
||||
changed.push(l1);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed.length === 0) return null;
|
||||
return changed.slice(0, 10).join('\n').substring(0, 1000);
|
||||
}
|
||||
|
||||
console.log('Correction tracker loaded');
|
||||
})();
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
// ============================================================
|
||||
// DOCUMENTS.JS — S3 document upload & management UI
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
function loadDocuments() {
|
||||
var container = document.getElementById('documents-list');
|
||||
if (!container) return;
|
||||
fetch('/api/documents', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.s3_configured) {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">S3 storage not configured. Set S3_BUCKET in server environment.</p>';
|
||||
var uploadArea = document.getElementById('doc-upload-area');
|
||||
if (uploadArea) uploadArea.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
var docs = data.documents || [];
|
||||
if (docs.length === 0) {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No documents uploaded yet.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = docs.map(function(doc) {
|
||||
var sizeStr = doc.size_bytes < 1024 ? doc.size_bytes + ' B' :
|
||||
doc.size_bytes < 1048576 ? Math.round(doc.size_bytes / 1024) + ' KB' :
|
||||
(doc.size_bytes / 1048576).toFixed(1) + ' MB';
|
||||
var date = doc.created_at ? new Date(doc.created_at).toLocaleDateString() : '';
|
||||
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<div style="font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' +
|
||||
'<i class="fas fa-file" style="margin-right:4px;color:var(--g400);"></i>' + esc(doc.filename) +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;color:var(--g500);">' + sizeStr + ' · ' + date +
|
||||
(doc.description ? ' · ' + esc(doc.description) : '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-primary doc-download-btn" data-id="' + doc.id + '"><i class="fas fa-download"></i></button>' +
|
||||
'<button class="btn-sm btn-ghost doc-delete-btn" data-id="' + doc.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.doc-download-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { downloadDocument(btn.dataset.id); });
|
||||
});
|
||||
container.querySelectorAll('.doc-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { deleteDocument(btn.dataset.id); });
|
||||
});
|
||||
})
|
||||
.catch(function() {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">Failed to load documents.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function uploadDocument() {
|
||||
var fileInput = document.getElementById('doc-file-input');
|
||||
var descInput = document.getElementById('doc-description');
|
||||
if (!fileInput || !fileInput.files[0]) { showToast('Select a file first', 'error'); return; }
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
formData.append('description', descInput ? descInput.value : '');
|
||||
|
||||
showLoading('Uploading document...');
|
||||
fetch('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
headers: window.IS_NATIVE_APP
|
||||
? { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || (window.SecureStorage && window.SecureStorage.getSync('ped_scribe_token')) || '') }
|
||||
: {}, // web: httpOnly cookie sent automatically via credentials:'same-origin' default
|
||||
credentials: 'same-origin',
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
showToast('Document uploaded: ' + data.filename, 'success');
|
||||
fileInput.value = '';
|
||||
if (descInput) descInput.value = '';
|
||||
loadDocuments();
|
||||
} else {
|
||||
showToast(data.error || 'Upload failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast('Upload failed: ' + err.message, 'error'); });
|
||||
}
|
||||
|
||||
function downloadDocument(id) {
|
||||
fetch('/api/documents/' + id + '/download', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success && data.url) {
|
||||
window.open(data.url, '_blank');
|
||||
} else {
|
||||
showToast(data.error || 'Download failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Download failed', 'error'); });
|
||||
}
|
||||
|
||||
function deleteDocument(id) {
|
||||
showConfirm('Delete this document permanently?', function() {
|
||||
fetch('/api/documents/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('Document deleted', 'info'); loadDocuments(); }
|
||||
else showToast(data.error || 'Delete failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Delete failed', 'error'); });
|
||||
}, { danger: true, confirmText: 'Delete' });
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Expose for settings tab load
|
||||
window.loadDocuments = loadDocuments;
|
||||
|
||||
// Wire upload button
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-doc-upload')) uploadDocument();
|
||||
});
|
||||
|
||||
console.log('Documents module loaded');
|
||||
})();
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
// ============================================================
|
||||
// drugs-loader.js
|
||||
// Fetches /data/drugs.json at page load and exposes:
|
||||
// window._DRUGS — the parsed JSON (after fetch resolves)
|
||||
// window._DRUGS_READY — a Promise that resolves to the JSON
|
||||
//
|
||||
// Calculator functions in calculators.js look up drugs via a small
|
||||
// helper (pickDrug) and fall back to hardcoded inline data if the
|
||||
// JSON isn't loaded yet. This means the app keeps working even if
|
||||
// the fetch fails — the JSON is an authoritative data source but
|
||||
// not a hard dependency at runtime.
|
||||
// ============================================================
|
||||
(function() {
|
||||
// Default to empty shape so lookups don't throw before fetch resolves.
|
||||
window._DRUGS = window._DRUGS || { version: null, sections: {} };
|
||||
|
||||
window._DRUGS_READY = fetch('/data/drugs.json', { credentials: 'same-origin' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('drugs.json HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(j) {
|
||||
window._DRUGS = j;
|
||||
return j;
|
||||
})
|
||||
.catch(function(err) {
|
||||
// Non-fatal — calc functions have inline fallbacks.
|
||||
console.warn('[drugs-loader] could not load /data/drugs.json:', err);
|
||||
return window._DRUGS;
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// E2E harness bootstrap — external file because the app's CSP blocks inline scripts.
|
||||
// Runs after calc-math.js + calculators.js (all three have `defer`, so execution
|
||||
// is in document order once parsing completes).
|
||||
(async function bootstrap() {
|
||||
try {
|
||||
// Cache-bust so stale playwright browser caches never serve an outdated
|
||||
// component HTML between reorg deploys.
|
||||
const cb = '?cb=' + Date.now();
|
||||
const [calcHtml, bedsideHtml] = await Promise.all([
|
||||
fetch('/components/calculators.html' + cb).then(r => r.text()),
|
||||
fetch('/components/bedside.html' + cb).then(r => r.text()),
|
||||
]);
|
||||
document.getElementById('calculators-tab').innerHTML = calcHtml;
|
||||
document.getElementById('bedside-tab').innerHTML = bedsideHtml;
|
||||
// Fire tabChanged for both so per-tab initialisers run.
|
||||
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: 'calculators' } }));
|
||||
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: 'bedside' } }));
|
||||
window.__harnessReady = true;
|
||||
} catch (e) {
|
||||
console.error('[e2e-harness] bootstrap failed:', e);
|
||||
window.__harnessError = String(e);
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,521 +0,0 @@
|
|||
// ============================================================
|
||||
// ENCOUNTERS.JS — Save/resume/pause encounter progress
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// ── Pause/Resume for recording buttons ─────────────────────────────────
|
||||
// Each recording module (encounter, dictation) gets a pause button wired here.
|
||||
|
||||
function wirePause(recordBtnId, pauseBtnId, getRecorder) {
|
||||
var pauseBtn = document.getElementById(pauseBtnId);
|
||||
if (!pauseBtn) return;
|
||||
var isPaused = false;
|
||||
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
var rec = getRecorder();
|
||||
if (!rec || !rec.mediaRecorder) return;
|
||||
if (!isPaused) {
|
||||
if (rec.mediaRecorder.state === 'recording') {
|
||||
rec.mediaRecorder.pause();
|
||||
isPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
pauseBtn.classList.remove('btn-ghost');
|
||||
pauseBtn.classList.add('btn-primary');
|
||||
}
|
||||
} else {
|
||||
if (rec.mediaRecorder.state === 'paused') {
|
||||
rec.mediaRecorder.resume();
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset when recording stops
|
||||
document.addEventListener('recording-stopped', function(e) {
|
||||
if (e.detail && e.detail.module === recordBtnId.replace('-record-btn', '')) {
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
pauseBtn.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
document.addEventListener('recording-started', function(e) {
|
||||
if (e.detail && e.detail.module === recordBtnId.replace('-record-btn', '')) {
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
pauseBtn.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Expose pause wiring globally (called by each recording module)
|
||||
window.wirePauseButton = wirePause;
|
||||
|
||||
// ── Saved Encounters Manager ───────────────────────────────────────────
|
||||
|
||||
var _savedEncounters = [];
|
||||
var _loadCallback = null; // set by each module to handle loading a saved encounter
|
||||
var _savingInProgress = {}; // prevent duplicate saves on double-click
|
||||
|
||||
// Generate a UUID v4 for idempotency keys
|
||||
function generateUUID() {
|
||||
if (crypto && crypto.randomUUID) return crypto.randomUUID();
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
var r = Math.random() * 16 | 0;
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
// Get or create idempotency key for a tab type
|
||||
function getIdempotencyKey(type) {
|
||||
var key = '_idempKey_' + type;
|
||||
if (!window[key]) {
|
||||
try { window[key] = sessionStorage.getItem(key); } catch(e) {}
|
||||
if (!window[key]) {
|
||||
window[key] = generateUUID();
|
||||
try { sessionStorage.setItem(key, window[key]); } catch(e) {}
|
||||
}
|
||||
}
|
||||
return window[key];
|
||||
}
|
||||
|
||||
// Reset idempotency key (on New Patient)
|
||||
function resetIdempotencyKey(type) {
|
||||
var key = '_idempKey_' + type;
|
||||
window[key] = null;
|
||||
try { sessionStorage.removeItem(key); } catch(e) {}
|
||||
}
|
||||
|
||||
// Restore saved encounter IDs from sessionStorage (survive page refresh, cleared on tab close)
|
||||
(function() {
|
||||
['encounter','dictation','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
|
||||
try {
|
||||
var id = sessionStorage.getItem('_savedEncId_' + t);
|
||||
if (id) window['_savedEncId_' + t] = id;
|
||||
} catch(e) {}
|
||||
});
|
||||
})();
|
||||
|
||||
// Register a load handler for a specific tab type
|
||||
window.registerEncounterLoadHandler = function(type, fn) {
|
||||
if (!window._encLoadHandlers) window._encLoadHandlers = {};
|
||||
window._encLoadHandlers[type] = fn;
|
||||
};
|
||||
|
||||
// Save current encounter state
|
||||
window.saveEncounter = function(opts) {
|
||||
// opts: { id, label, enc_type, transcript, generated_note, partial_data, onSaved }
|
||||
if (!opts.label || !opts.label.trim()) { showToast('Enter a patient label first', 'error'); return; }
|
||||
var type = opts.enc_type || 'encounter';
|
||||
// Prevent duplicate saves from double-click
|
||||
if (_savingInProgress[type]) { showToast('Save in progress…', 'info'); return; }
|
||||
_savingInProgress[type] = true;
|
||||
// Optimistic locking: send the version we last saw. Server returns 409
|
||||
// if the encounter was updated elsewhere (another tab, another device).
|
||||
var body = Object.assign({}, opts);
|
||||
if (opts.id && window._encounterVersions && window._encounterVersions[opts.id] != null) {
|
||||
body.expected_version = window._encounterVersions[opts.id];
|
||||
}
|
||||
fetch('/api/encounters/saved', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { return r.json().then(function(d) { d._status = r.status; return d; }); })
|
||||
.then(function(data) {
|
||||
_savingInProgress[type] = false;
|
||||
if (data._status === 409) {
|
||||
showToast('Someone else edited this encounter. Reload to see the latest version.', 'error');
|
||||
return;
|
||||
}
|
||||
if (data.success) {
|
||||
showToast('Encounter saved (' + (opts.label || '') + ')', 'success');
|
||||
// Track the new version the server assigned
|
||||
if (data.id != null && data.version != null) {
|
||||
window._encounterVersions = window._encounterVersions || {};
|
||||
window._encounterVersions[data.id] = data.version;
|
||||
}
|
||||
// Persist ID so refreshing the page won't create a duplicate
|
||||
if (data.id) {
|
||||
if (opts.onSaved) opts.onSaved(data.id);
|
||||
try { sessionStorage.setItem('_savedEncId_' + type, data.id); } catch(e) {}
|
||||
}
|
||||
loadSavedEncountersList();
|
||||
} else {
|
||||
showToast(data.error || 'Save failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { _savingInProgress[type] = false; showToast('Save failed', 'error'); });
|
||||
};
|
||||
|
||||
// Load list and refresh all displays (settings page + open popovers)
|
||||
function loadSavedEncountersList(targetContainer) {
|
||||
fetch('/api/encounters/saved', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
_savedEncounters = data.encounters || [];
|
||||
renderSavedList();
|
||||
// Refresh any open popovers
|
||||
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(pfx) {
|
||||
var pop = document.getElementById(pfx + '-load-popover');
|
||||
if (pop && !pop.classList.contains('hidden')) {
|
||||
var search = pop.querySelector('.enc-load-search');
|
||||
renderPopoverList(pfx, search ? search.value : '');
|
||||
}
|
||||
});
|
||||
if (targetContainer) {
|
||||
renderPopoverList(targetContainer, '');
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function renderPopoverList(pfx, query) {
|
||||
var listEl = document.getElementById(pfx + '-pop-list');
|
||||
if (!listEl) return;
|
||||
var filtered = _savedEncounters.filter(function(enc) {
|
||||
if (!query) return true;
|
||||
return (enc.label || '').toLowerCase().indexOf(query.toLowerCase()) !== -1 ||
|
||||
(enc.enc_type || '').toLowerCase().indexOf(query.toLowerCase()) !== -1;
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding:10px 12px;color:var(--g400);font-size:13px;">' + (query ? 'No matches.' : 'No saved encounters.') + '</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = filtered.map(function(enc) {
|
||||
var date = enc.updated_at ? new Date(enc.updated_at).toLocaleDateString() : '';
|
||||
return '<div class="enc-pop-item" data-id="' + enc.id + '" data-type="' + esc(enc.enc_type) + '">' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<div style="font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + esc(enc.label) + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--g500);">' + esc(enc.enc_type) + ' · ' + date + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-ghost enc-pop-delete" data-id="' + enc.id + '" style="color:var(--red);flex-shrink:0;" title="Delete"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
listEl.querySelectorAll('.enc-pop-item').forEach(function(item) {
|
||||
item.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.enc-pop-delete')) return;
|
||||
resumeEncounter(item.dataset.id, item.dataset.type);
|
||||
// close popover
|
||||
var pop = item.closest('.enc-load-popover');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('.enc-pop-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
deleteEncounter(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSavedList() {
|
||||
var container = document.getElementById('saved-enc-list');
|
||||
if (!container) return;
|
||||
if (_savedEncounters.length === 0) {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No saved encounters.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = _savedEncounters.map(function(enc) {
|
||||
var date = enc.updated_at ? new Date(enc.updated_at).toLocaleDateString() : '';
|
||||
var expires = enc.expires_at ? new Date(enc.expires_at).toLocaleDateString() : '';
|
||||
var preview = (enc.transcript_preview || '').substring(0, 80);
|
||||
return '<div class="saved-enc-item" data-id="' + enc.id + '">' +
|
||||
'<div style="flex:1;">' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
|
||||
'<span class="saved-enc-label">' + esc(enc.label) + '</span>' +
|
||||
'<span class="saved-enc-type">' + esc(enc.enc_type) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="saved-enc-meta">' + date + ' · expires ' + expires + (preview ? ' · ' + esc(preview) + '...' : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-primary enc-resume-btn" data-id="' + enc.id + '" data-type="' + esc(enc.enc_type) + '">Resume</button>' +
|
||||
'<button class="btn-sm btn-ghost enc-delete-btn" data-id="' + enc.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.enc-resume-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { resumeEncounter(btn.dataset.id, btn.dataset.type); });
|
||||
});
|
||||
container.querySelectorAll('.enc-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { deleteEncounter(btn.dataset.id); });
|
||||
});
|
||||
}
|
||||
|
||||
function openLoadPopover(pfx) {
|
||||
var popover = document.getElementById(pfx + '-load-popover');
|
||||
if (!popover) return;
|
||||
var isHidden = popover.classList.contains('hidden');
|
||||
// Close all popovers first
|
||||
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
|
||||
var pop = document.getElementById(p + '-load-popover');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
if (!isHidden) return; // was open, now closed
|
||||
popover.classList.remove('hidden');
|
||||
var searchEl = popover.querySelector('.enc-load-search');
|
||||
if (searchEl) { searchEl.value = ''; searchEl.focus(); }
|
||||
if (_savedEncounters.length === 0) {
|
||||
loadSavedEncountersList();
|
||||
} else {
|
||||
renderPopoverList(pfx, '');
|
||||
}
|
||||
// Wire search
|
||||
if (searchEl && !searchEl._wired) {
|
||||
searchEl._wired = true;
|
||||
searchEl.addEventListener('input', function() { renderPopoverList(pfx, this.value); });
|
||||
}
|
||||
// Wire close button — stop propagation so doc handler doesn't re-process
|
||||
var closeBtn = popover.querySelector('.enc-pop-close');
|
||||
if (closeBtn && !closeBtn._wired) {
|
||||
closeBtn._wired = true;
|
||||
closeBtn.addEventListener('click', function(e) { e.stopPropagation(); popover.classList.add('hidden'); });
|
||||
}
|
||||
// Stop click propagation inside popover so outside-click handler doesn't fire for inner clicks
|
||||
if (!popover._stopPropWired) {
|
||||
popover._stopPropWired = true;
|
||||
popover.addEventListener('click', function(e) { e.stopPropagation(); });
|
||||
}
|
||||
}
|
||||
|
||||
function resumeEncounter(id, type) {
|
||||
fetch('/api/encounters/saved/' + id, { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
|
||||
var enc = data.encounter;
|
||||
// Remember the loaded version so future saves can detect drift
|
||||
if (enc && enc.id != null && enc.version != null) {
|
||||
window._encounterVersions = window._encounterVersions || {};
|
||||
window._encounterVersions[enc.id] = enc.version;
|
||||
}
|
||||
// Close all popovers
|
||||
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
|
||||
var pop = document.getElementById(p + '-load-popover');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Navigate to correct tab
|
||||
var tabMap = {
|
||||
encounter: 'encounter', dictation: 'dictation',
|
||||
hospital: 'hospital', chart: 'chart',
|
||||
soap: 'soap', milestones: 'milestones',
|
||||
wellvisit: 'wellvisit', sickvisit: 'sickvisit'
|
||||
};
|
||||
var tabName = tabMap[enc.enc_type] || 'encounter';
|
||||
var tabBtn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
|
||||
if (tabBtn) tabBtn.click();
|
||||
|
||||
// Map enc_type → DOM element prefix
|
||||
var domPfxMap = {
|
||||
encounter: 'enc', dictation: 'dict', hospital: 'hosp', chart: 'chart',
|
||||
wellvisit: 'wv', sickvisit: 'sick', soap: 'soap', milestones: 'ms'
|
||||
};
|
||||
var domPfx = domPfxMap[enc.enc_type] || enc.enc_type;
|
||||
var noteIdMap = {
|
||||
encounter: 'enc-hpi-text', dictation: 'dict-hpi-text',
|
||||
hospital: 'hc-course-text', chart: 'cr-review-text',
|
||||
wellvisit: 'wv-note-text', sickvisit: 'sick-note-text',
|
||||
soap: 'soap-text'
|
||||
};
|
||||
|
||||
// Fill in data
|
||||
setTimeout(function() {
|
||||
// Fill label
|
||||
var labelEl = document.getElementById(domPfx + '-label');
|
||||
if (labelEl) labelEl.value = enc.label;
|
||||
|
||||
// Set saved ID so next save updates same record
|
||||
window['_savedEncId_' + enc.enc_type] = enc.id;
|
||||
|
||||
// Call tab-specific handler
|
||||
if (window._encLoadHandlers && window._encLoadHandlers[enc.enc_type]) {
|
||||
window._encLoadHandlers[enc.enc_type](enc);
|
||||
} else {
|
||||
// Default: fill transcript and generated note
|
||||
var transcriptEl = document.getElementById(domPfx + '-transcript');
|
||||
if (transcriptEl && enc.transcript) transcriptEl.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
var noteEl = document.getElementById(noteIdMap[enc.enc_type] || (domPfx + '-hpi-text'));
|
||||
if (noteEl) {
|
||||
noteEl.textContent = enc.generated_note;
|
||||
var outputCard = noteEl.closest('.output-card');
|
||||
if (outputCard) outputCard.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
showToast('Encounter "' + enc.label + '" loaded', 'success');
|
||||
}, 200);
|
||||
})
|
||||
.catch(function() { showToast('Load failed', 'error'); });
|
||||
}
|
||||
|
||||
function deleteEncounter(id) {
|
||||
fetch('/api/encounters/saved/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('Deleted', 'info'); loadSavedEncountersList(); }
|
||||
else showToast(data.error || 'Delete failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Delete failed', 'error'); });
|
||||
}
|
||||
|
||||
// Expose loadSavedEncountersList globally so auth.js / settings page can call it
|
||||
window.loadSavedEncountersList = loadSavedEncountersList;
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
// Copy milestones narrative to visit note
|
||||
if (e.target.closest('#btn-ms-copy-to-note')) {
|
||||
var msText = document.getElementById('ms-narrative-text');
|
||||
var wvMsEl = document.getElementById('wv-milestones-text');
|
||||
if (msText && wvMsEl) {
|
||||
var content = (msText.innerText || msText.textContent || '').trim();
|
||||
if (content) {
|
||||
wvMsEl.value = content;
|
||||
showToast('Copied! Switching to Visit Note…', 'success');
|
||||
// Navigate to well visit note subtab so user can see the result
|
||||
if (typeof window.activateTab === 'function') window.activateTab('wellvisit');
|
||||
if (typeof window.wvSwitchSubtab === 'function') {
|
||||
setTimeout(function() { window.wvSwitchSubtab('note'); }, 50);
|
||||
}
|
||||
} else {
|
||||
showToast('Generate a milestone assessment first', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Settings button — refresh saved encounters list
|
||||
if (e.target.closest('#btn-settings')) {
|
||||
loadSavedEncountersList();
|
||||
}
|
||||
// Save buttons per tab
|
||||
if (e.target.closest('#btn-enc-save')) saveFromTab('encounter', 'enc');
|
||||
if (e.target.closest('#btn-dict-save')) saveFromTab('dictation', 'dict');
|
||||
if (e.target.closest('#btn-hosp-save')) saveFromTab('hospital', 'hosp');
|
||||
if (e.target.closest('#btn-chart-save')) saveFromTab('chart', 'chart');
|
||||
if (e.target.closest('#btn-wv-save')) saveFromTab('wellvisit', 'wv');
|
||||
if (e.target.closest('#btn-soap-save')) saveFromTab('soap', 'soap');
|
||||
// Load buttons — open inline popovers
|
||||
if (e.target.closest('#btn-enc-load')) openLoadPopover('enc');
|
||||
if (e.target.closest('#btn-dict-load')) openLoadPopover('dict');
|
||||
if (e.target.closest('#btn-wv-load')) openLoadPopover('wv');
|
||||
if (e.target.closest('#btn-sick-load')) openLoadPopover('sick');
|
||||
if (e.target.closest('#btn-hosp-load')) openLoadPopover('hosp');
|
||||
if (e.target.closest('#btn-chart-load')) openLoadPopover('chart');
|
||||
if (e.target.closest('#btn-soap-load')) openLoadPopover('soap');
|
||||
// New Patient / clear tab buttons
|
||||
if (e.target.closest('#btn-enc-new')) clearTab('encounter');
|
||||
if (e.target.closest('#btn-dict-new')) clearTab('dictation');
|
||||
if (e.target.closest('#btn-hosp-new')) clearTab('hospital');
|
||||
if (e.target.closest('#btn-chart-new')) clearTab('chart');
|
||||
if (e.target.closest('#btn-wv-new')) clearTab('wellvisit');
|
||||
if (e.target.closest('#btn-sick-new')) clearTab('sickvisit');
|
||||
if (e.target.closest('#btn-soap-new')) clearTab('soap');
|
||||
// Close popovers when clicking outside
|
||||
var loadBtnIds = ['#btn-enc-load','#btn-dict-load','#btn-wv-load','#btn-sick-load','#btn-hosp-load','#btn-chart-load','#btn-soap-load'];
|
||||
var clickedLoadBtn = loadBtnIds.some(function(id) { return e.target.closest(id); });
|
||||
if (!clickedLoadBtn) {
|
||||
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
|
||||
var pop = document.getElementById(p + '-load-popover');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function saveFromTab(type, prefix) {
|
||||
var labelEl = document.getElementById(prefix + '-label');
|
||||
var label = labelEl ? labelEl.value.trim() : '';
|
||||
if (!label) { showToast('Enter a patient label before saving', 'error'); return; }
|
||||
|
||||
var savedId = window['_savedEncId_' + type] || null;
|
||||
|
||||
var transcriptEl = document.getElementById(prefix + '-transcript');
|
||||
// Each tab stores its generated note in a different element
|
||||
var noteIdMap = {
|
||||
encounter: 'enc-hpi-text', dictation: 'dict-hpi-text',
|
||||
hospital: 'hc-course-text', chart: 'cr-review-text',
|
||||
wellvisit: 'wv-note-text', sickvisit: 'sick-note-text', soap: 'soap-text'
|
||||
};
|
||||
var noteEl = document.getElementById(noteIdMap[type] || (prefix + '-hpi-text'));
|
||||
|
||||
// Save any corrections (Dragon-like learning) before saving encounter
|
||||
var noteElId = noteIdMap[type] || (prefix + '-hpi-text');
|
||||
if (typeof saveCorrection === 'function') {
|
||||
saveCorrection(noteElId, type);
|
||||
}
|
||||
|
||||
window.saveEncounter({
|
||||
id: savedId,
|
||||
label: label,
|
||||
enc_type: type,
|
||||
transcript: transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '',
|
||||
generated_note: noteEl ? (noteEl.innerText || noteEl.textContent || '') : '',
|
||||
idempotency_key: getIdempotencyKey(type),
|
||||
onSaved: function(newId) { window['_savedEncId_' + type] = newId; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── New Patient / Clear tab ────────────────────────────────────────────────
|
||||
function clearTab(type) {
|
||||
var pfxMap = { encounter:'enc', dictation:'dict', hospital:'hosp', chart:'chart', wellvisit:'wv', sickvisit:'sick', soap:'soap' };
|
||||
var pfx = pfxMap[type] || type;
|
||||
var noteElMap = {
|
||||
encounter:'enc-hpi-text', dictation:'dict-hpi-text',
|
||||
hospital:'hc-course-text', chart:'cr-review-text',
|
||||
wellvisit:'wv-note-text', sickvisit:'sick-note-text',
|
||||
soap:'soap-text'
|
||||
};
|
||||
var outputElMap = {
|
||||
encounter:'enc-output', dictation:'dict-output',
|
||||
hospital:'hc-output', chart:'cr-output',
|
||||
wellvisit:'wv-note-output', sickvisit:'sick-note-output',
|
||||
soap:'soap-output'
|
||||
};
|
||||
// Clear label
|
||||
var lbl = document.getElementById(pfx + '-label');
|
||||
if (lbl) lbl.value = '';
|
||||
// Clear transcript (contenteditable or textarea)
|
||||
var tr = document.getElementById(pfx + '-transcript');
|
||||
if (tr) { tr.textContent = ''; tr.innerHTML = ''; }
|
||||
// Clear generated note
|
||||
var note = document.getElementById(noteElMap[type]);
|
||||
if (note) { note.textContent = ''; note.innerHTML = ''; }
|
||||
// Hide output card
|
||||
var out = document.getElementById(outputElMap[type]);
|
||||
if (out) out.classList.add('hidden');
|
||||
// Clear refine input (textarea or input) for every tab
|
||||
var refineEl = document.getElementById(pfx + '-refine-input');
|
||||
if (refineEl) refineEl.value = '';
|
||||
// Clear instructions textarea (SOAP, hospital, etc.)
|
||||
var instrEl = document.getElementById(pfx + '-instructions');
|
||||
if (instrEl) instrEl.value = '';
|
||||
// Clear demographic fields
|
||||
var ageEl = document.getElementById(pfx + '-age');
|
||||
if (ageEl) ageEl.value = '';
|
||||
var genderEl = document.getElementById(pfx + '-gender');
|
||||
if (genderEl) genderEl.value = '';
|
||||
// Reset saved ID and idempotency key (memory + sessionStorage)
|
||||
window['_savedEncId_' + type] = null;
|
||||
try { sessionStorage.removeItem('_savedEncId_' + type); } catch(e) {}
|
||||
resetIdempotencyKey(type);
|
||||
// Chart review has additional fields/cards to clear
|
||||
if (type === 'chart' && typeof window.resetChartReview === 'function') {
|
||||
window.resetChartReview();
|
||||
}
|
||||
showToast('Ready for new patient', 'info');
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
|
||||
console.log('✅ Encounters module loaded');
|
||||
|
||||
})();
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
// ============================================================
|
||||
// PAGERS & EXTENSIONS — personal directory with async search + soft delete
|
||||
// ============================================================
|
||||
(function () {
|
||||
var _inited = false;
|
||||
var _items = [];
|
||||
var _mode = 'active'; // 'active' | 'trash'
|
||||
var _editId = null; // null = creating, non-null = editing
|
||||
var _searchDebounce = null;
|
||||
|
||||
document.addEventListener('tabChanged', function (e) {
|
||||
if (e.detail.tab !== 'extensions' || _inited) return;
|
||||
_inited = true;
|
||||
init();
|
||||
});
|
||||
|
||||
function init() {
|
||||
document.getElementById('ext-add-btn').addEventListener('click', onAddClick);
|
||||
document.getElementById('ext-cancel-btn').addEventListener('click', hideForm);
|
||||
document.getElementById('ext-save-btn').addEventListener('click', saveItem);
|
||||
document.getElementById('ext-trash-btn').addEventListener('click', toggleTrashMode);
|
||||
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
|
||||
|
||||
var searchInp = document.getElementById('ext-search');
|
||||
searchInp.addEventListener('input', function () {
|
||||
clearTimeout(_searchDebounce);
|
||||
_searchDebounce = setTimeout(load, 200);
|
||||
});
|
||||
|
||||
// Keyboard: Esc cancels form
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !document.getElementById('ext-form-wrap').classList.contains('hidden')) {
|
||||
hideForm();
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
loadTrashCount();
|
||||
}
|
||||
|
||||
function loadTrashCount() {
|
||||
fetch('/api/extensions?trash=1', { headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d.success) {
|
||||
var n = (d.items || []).length;
|
||||
document.getElementById('ext-trash-count').textContent = n ? '(' + n + ')' : '';
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function load() {
|
||||
var q = document.getElementById('ext-search').value.trim();
|
||||
var url = '/api/extensions?' + (_mode === 'trash' ? 'trash=1&' : '') + (q ? 'q=' + encodeURIComponent(q) : '');
|
||||
var listEl = document.getElementById('ext-list');
|
||||
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:30px;">Loading...</p>';
|
||||
|
||||
fetch(url, { headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) { listEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:20px;">' + esc(d.error || 'Load failed') + '</p>'; return; }
|
||||
_items = d.items || [];
|
||||
render();
|
||||
refreshLocationList();
|
||||
})
|
||||
.catch(function (err) {
|
||||
listEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:20px;">Load failed: ' + esc(err.message) + '</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function refreshLocationList() {
|
||||
// Populate datalist with existing unique locations for autocomplete
|
||||
var dl = document.getElementById('ext-location-list');
|
||||
if (!dl) return;
|
||||
var seen = {};
|
||||
var opts = [];
|
||||
_items.forEach(function (x) {
|
||||
if (!seen[x.location]) { seen[x.location] = true; opts.push(x.location); }
|
||||
});
|
||||
dl.innerHTML = opts.map(function (l) { return '<option value="' + esc(l) + '">'; }).join('');
|
||||
}
|
||||
|
||||
function render() {
|
||||
var listEl = document.getElementById('ext-list');
|
||||
if (_items.length === 0) {
|
||||
var msg = _mode === 'trash' ? 'Trash is empty.' : 'No entries yet. Click Add to create your first one.';
|
||||
var q = document.getElementById('ext-search').value.trim();
|
||||
if (q) msg = 'No matches for "' + esc(q) + '".';
|
||||
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">' + msg + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by location → type
|
||||
var byLoc = {};
|
||||
_items.forEach(function (x) {
|
||||
if (!byLoc[x.location]) byLoc[x.location] = { extension: [], pager: [] };
|
||||
(byLoc[x.location][x.type] || byLoc[x.location].extension).push(x);
|
||||
});
|
||||
|
||||
var html = '';
|
||||
Object.keys(byLoc).sort().forEach(function (loc) {
|
||||
html += '<div class="ext-loc-group" style="margin-bottom:18px;">';
|
||||
html += '<h3 style="font-size:14px;color:var(--g700);margin:12px 0 8px;display:flex;align-items:center;gap:8px;"><i class="fas fa-map-marker-alt" style="color:var(--g400);"></i> ' + esc(loc) + '</h3>';
|
||||
|
||||
['extension', 'pager'].forEach(function (type) {
|
||||
var arr = byLoc[loc][type];
|
||||
if (!arr || arr.length === 0) return;
|
||||
html += '<div style="margin-left:4px;">';
|
||||
html += '<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--g500);font-weight:600;margin:6px 0 4px;">' + (type === 'pager' ? '📟 Pagers' : '☎️ Extensions') + '</div>';
|
||||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;">';
|
||||
arr.forEach(function (x) { html += renderCard(x); });
|
||||
html += '</div></div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
listEl.innerHTML = html;
|
||||
|
||||
// Wire per-card events
|
||||
listEl.querySelectorAll('[data-ext-action]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var action = btn.dataset.extAction;
|
||||
var id = parseInt(btn.dataset.extId, 10);
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
if (action === 'edit') return startEdit(id);
|
||||
if (action === 'delete') return confirmDelete(id);
|
||||
if (action === 'restore') return restoreItem(id);
|
||||
if (action === 'purge') return confirmPurge(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderCard(x) {
|
||||
var typeColor = x.type === 'pager' ? '#7c3aed' : '#2563eb';
|
||||
var typeLabel = x.type === 'pager' ? 'Pager' : 'Ext';
|
||||
var trashed = x.trashed_at != null;
|
||||
var bg = trashed ? '#fafaf9' : 'var(--white, #fff)';
|
||||
var border = trashed ? 'var(--g300)' : 'var(--g200)';
|
||||
|
||||
var actions;
|
||||
if (trashed) {
|
||||
actions =
|
||||
'<button class="btn-sm btn-ghost" data-ext-action="restore" data-ext-id="' + x.id + '" title="Restore"><i class="fas fa-rotate-left"></i></button>' +
|
||||
'<button class="btn-sm btn-ghost" data-ext-action="purge" data-ext-id="' + x.id + '" title="Delete permanently" style="color:var(--red);"><i class="fas fa-xmark"></i></button>';
|
||||
} else {
|
||||
actions =
|
||||
'<button class="btn-sm btn-ghost" data-ext-action="edit" data-ext-id="' + x.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
|
||||
'<button class="btn-sm btn-ghost" data-ext-action="delete" data-ext-id="' + x.id + '" title="Move to trash"><i class="fas fa-trash-can"></i></button>';
|
||||
}
|
||||
|
||||
var html = '';
|
||||
html += '<div class="ext-card" style="background:' + bg + ';border:1px solid ' + border + ';border-radius:10px;padding:12px;display:flex;gap:12px;align-items:center;transition:border-color 0.15s,box-shadow 0.15s;">';
|
||||
html += ' <div style="flex:1;min-width:0;">';
|
||||
html += ' <div style="display:flex;align-items:baseline;gap:8px;">';
|
||||
html += ' <div style="font-size:22px;font-weight:700;color:' + typeColor + ';font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:0.5px;">' + esc(x.number) + '</div>';
|
||||
html += ' <span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:6px;background:' + typeColor + '22;color:' + typeColor + ';text-transform:uppercase;letter-spacing:0.5px;">' + typeLabel + '</span>';
|
||||
html += ' </div>';
|
||||
html += ' <div style="font-size:13px;color:var(--g700);margin-top:2px;">' + esc(x.name) + '</div>';
|
||||
if (x.notes) html += ' <div style="font-size:11px;color:var(--g500);margin-top:2px;">' + esc(x.notes) + '</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div style="display:flex;gap:2px;flex-shrink:0;">' + actions + '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function onAddClick() {
|
||||
_editId = null;
|
||||
document.getElementById('ext-location').value = '';
|
||||
document.getElementById('ext-name').value = '';
|
||||
document.getElementById('ext-number').value = '';
|
||||
document.getElementById('ext-type').value = 'extension';
|
||||
document.getElementById('ext-notes').value = '';
|
||||
document.getElementById('ext-form-status').textContent = '';
|
||||
document.getElementById('ext-save-btn').innerHTML = '<i class="fas fa-floppy-disk"></i> Save';
|
||||
document.getElementById('ext-form-wrap').classList.remove('hidden');
|
||||
document.getElementById('ext-location').focus();
|
||||
}
|
||||
|
||||
function hideForm() {
|
||||
document.getElementById('ext-form-wrap').classList.add('hidden');
|
||||
_editId = null;
|
||||
}
|
||||
|
||||
function startEdit(id) {
|
||||
var item = _items.filter(function (x) { return x.id === id; })[0];
|
||||
if (!item) return;
|
||||
_editId = id;
|
||||
document.getElementById('ext-location').value = item.location;
|
||||
document.getElementById('ext-name').value = item.name;
|
||||
document.getElementById('ext-number').value = item.number;
|
||||
document.getElementById('ext-type').value = item.type;
|
||||
document.getElementById('ext-notes').value = item.notes || '';
|
||||
document.getElementById('ext-form-status').textContent = '';
|
||||
document.getElementById('ext-save-btn').innerHTML = '<i class="fas fa-check"></i> Update';
|
||||
document.getElementById('ext-form-wrap').classList.remove('hidden');
|
||||
document.getElementById('ext-location').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function saveItem() {
|
||||
var body = {
|
||||
location: document.getElementById('ext-location').value.trim(),
|
||||
name: document.getElementById('ext-name').value.trim(),
|
||||
number: document.getElementById('ext-number').value.trim(),
|
||||
type: document.getElementById('ext-type').value,
|
||||
notes: document.getElementById('ext-notes').value.trim()
|
||||
};
|
||||
if (!body.location || !body.name || !body.number) {
|
||||
document.getElementById('ext-form-status').textContent = 'Location, name, and number are required.';
|
||||
document.getElementById('ext-form-status').style.color = 'var(--red)';
|
||||
return;
|
||||
}
|
||||
var url = _editId ? ('/api/extensions/' + _editId) : '/api/extensions';
|
||||
var method = _editId ? 'PUT' : 'POST';
|
||||
document.getElementById('ext-save-btn').disabled = true;
|
||||
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(body) })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) { showToast(d.error || 'Save failed', 'error'); return; }
|
||||
hideForm();
|
||||
showToast(_editId ? 'Updated' : 'Added', 'success');
|
||||
load();
|
||||
})
|
||||
.catch(function () { showToast('Save failed', 'error'); })
|
||||
.finally(function () { document.getElementById('ext-save-btn').disabled = false; });
|
||||
}
|
||||
|
||||
function confirmDelete(id) {
|
||||
var item = _items.filter(function (x) { return x.id === id; })[0];
|
||||
if (!item) return;
|
||||
showConfirm(
|
||||
'Move "' + item.name + ' (' + item.number + ')" to trash? You can restore it later from the trash view.',
|
||||
function () {
|
||||
fetch('/api/extensions/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) { showToast(d.error || 'Delete failed', 'error'); return; }
|
||||
showToast('Moved to trash', 'success');
|
||||
load();
|
||||
loadTrashCount();
|
||||
})
|
||||
.catch(function () { showToast('Delete failed', 'error'); });
|
||||
},
|
||||
{ confirmText: 'Move to trash' }
|
||||
);
|
||||
}
|
||||
|
||||
function restoreItem(id) {
|
||||
fetch('/api/extensions/' + id + '/restore', { method: 'POST', headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) { showToast(d.error || 'Restore failed', 'error'); return; }
|
||||
showToast('Restored', 'success');
|
||||
load();
|
||||
loadTrashCount();
|
||||
})
|
||||
.catch(function () { showToast('Restore failed', 'error'); });
|
||||
}
|
||||
|
||||
function confirmPurge(id) {
|
||||
var item = _items.filter(function (x) { return x.id === id; })[0];
|
||||
if (!item) return;
|
||||
showConfirm(
|
||||
'Permanently delete "' + item.name + ' (' + item.number + ')"? This cannot be undone.',
|
||||
function () {
|
||||
fetch('/api/extensions/' + id + '/purge', { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) { showToast(d.error || 'Delete failed', 'error'); return; }
|
||||
showToast('Permanently deleted', 'success');
|
||||
load();
|
||||
loadTrashCount();
|
||||
})
|
||||
.catch(function () { showToast('Delete failed', 'error'); });
|
||||
},
|
||||
{ danger: true, confirmText: 'Delete permanently' }
|
||||
);
|
||||
}
|
||||
|
||||
function toggleTrashMode() {
|
||||
_mode = (_mode === 'trash') ? 'active' : 'trash';
|
||||
updateModeBanner();
|
||||
load();
|
||||
}
|
||||
function updateModeBanner() {
|
||||
var banner = document.getElementById('ext-mode-banner');
|
||||
var trashBtn = document.getElementById('ext-trash-btn');
|
||||
if (_mode === 'trash') {
|
||||
banner.classList.remove('hidden');
|
||||
trashBtn.innerHTML = '<i class="fas fa-list"></i> Active items';
|
||||
document.getElementById('ext-add-btn').style.display = 'none';
|
||||
} else {
|
||||
banner.classList.add('hidden');
|
||||
trashBtn.innerHTML = '<i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span>';
|
||||
document.getElementById('ext-add-btn').style.display = '';
|
||||
loadTrashCount();
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
(function() {
|
||||
var _inited = false;
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'hospital' || _inited) return;
|
||||
_inited = true;
|
||||
var notesContainer = document.getElementById('hc-notes-container');
|
||||
var addNoteBtn = document.getElementById('hc-add-note');
|
||||
var labsContainer = document.getElementById('hc-labs-container');
|
||||
var addLabBtn = document.getElementById('hc-add-lab');
|
||||
var generateBtn = document.getElementById('hc-generate-btn');
|
||||
var outputCard = document.getElementById('hc-output');
|
||||
var courseText = document.getElementById('hc-course-text');
|
||||
var formatTag = document.getElementById('hc-format-tag');
|
||||
var modelTag = document.getElementById('hc-model-tag');
|
||||
var clarifyOutput = document.getElementById('hc-clarify-output');
|
||||
|
||||
var noteIndex = 1;
|
||||
|
||||
// Add progress note
|
||||
addNoteBtn.addEventListener('click', function() {
|
||||
noteIndex++;
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card note-card';
|
||||
card.innerHTML = '<div class="card-header"><h3><i class="fas fa-notes-medical"></i> Progress Note #' + noteIndex + '</h3>' +
|
||||
'<button class="btn-sm btn-ghost remove-note-btn"><i class="fas fa-trash"></i></button></div>' +
|
||||
'<div class="note-entry"><div class="note-meta">' +
|
||||
'<input type="date" class="note-date">' +
|
||||
'<select class="note-type"><option value="progress-attending">Progress - Attending</option><option value="progress-resident">Progress - Resident</option><option value="progress-np">Progress - NP/PA</option><option value="consult">Consult Note</option><option value="procedure">Procedure Note</option><option value="discharge-summary">Discharge Summary</option></select></div>' +
|
||||
'<div class="editable-box note-content" contenteditable="true" data-placeholder="Paste or type note..."></div>' +
|
||||
'<div class="note-dictate"><button class="btn-sm btn-ghost hc-dictate-btn"><i class="fas fa-microphone"></i> Dictate</button></div></div>';
|
||||
notesContainer.appendChild(card);
|
||||
|
||||
card.querySelector('.remove-note-btn').addEventListener('click', function() { card.remove(); });
|
||||
setupDictateButton(card.querySelector('.hc-dictate-btn'), card.querySelector('.note-content'));
|
||||
});
|
||||
|
||||
// Remove note buttons for initial note
|
||||
document.querySelectorAll('.remove-note-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { btn.closest('.note-card').remove(); });
|
||||
});
|
||||
|
||||
// Add lab
|
||||
addLabBtn.addEventListener('click', function() {
|
||||
var entry = document.createElement('div');
|
||||
entry.className = 'lab-entry';
|
||||
entry.innerHTML = '<input type="date" class="lab-date"><textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>' +
|
||||
'<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>';
|
||||
labsContainer.appendChild(entry);
|
||||
});
|
||||
|
||||
// Dictate button helper
|
||||
function setupDictateButton(btn, targetEl) {
|
||||
var rec = new AudioRecorder();
|
||||
var isRec = false;
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
if (!isRec) {
|
||||
rec.start().then(function() {
|
||||
isRec = true;
|
||||
btn.innerHTML = '<i class="fas fa-stop"></i> Stop';
|
||||
btn.classList.add('btn-recording');
|
||||
});
|
||||
} else {
|
||||
isRec = false;
|
||||
btn.innerHTML = '<i class="fas fa-microphone"></i> Dictate';
|
||||
btn.classList.remove('btn-recording');
|
||||
|
||||
showBusy('Transcribing...');
|
||||
rec.stop().then(function(blob) {
|
||||
if (!blob) { hideBusy(); return; }
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
var existing = targetEl.innerText.trim();
|
||||
targetEl.textContent = existing ? existing + '\n' + data.text : data.text;
|
||||
showToast('Transcribed!', 'success');
|
||||
}
|
||||
});
|
||||
}).catch(function() { hideBusy(); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup dictate buttons for initial elements
|
||||
document.querySelectorAll('.hc-dictate-btn').forEach(function(btn) {
|
||||
var targetId = btn.getAttribute('data-target');
|
||||
if (targetId) {
|
||||
setupDictateButton(btn, document.getElementById(targetId));
|
||||
} else {
|
||||
var card = btn.closest('.note-card');
|
||||
if (card) setupDictateButton(btn, card.querySelector('.note-content'));
|
||||
}
|
||||
});
|
||||
|
||||
// Collect all data and generate
|
||||
generateBtn.addEventListener('click', function() {
|
||||
var notes = [];
|
||||
notesContainer.querySelectorAll('.note-card').forEach(function(card) {
|
||||
var content = card.querySelector('.note-content').innerText.trim();
|
||||
if (content) {
|
||||
notes.push({
|
||||
date: card.querySelector('.note-date').value || '',
|
||||
type: card.querySelector('.note-type').value || 'progress-attending',
|
||||
content: content
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var edContent = document.getElementById('hc-ed-content').innerText.trim();
|
||||
var edNote = edContent ? {
|
||||
date: document.getElementById('hc-ed-date').value || '',
|
||||
content: edContent,
|
||||
labs: document.getElementById('hc-ed-labs').value || ''
|
||||
} : null;
|
||||
|
||||
var hpContent = document.getElementById('hc-hp-content').innerText.trim();
|
||||
var hAndP = hpContent ? {
|
||||
date: document.getElementById('hc-hp-date').value || '',
|
||||
content: hpContent
|
||||
} : null;
|
||||
|
||||
var labs = [];
|
||||
labsContainer.querySelectorAll('.lab-entry').forEach(function(entry) {
|
||||
var vals = entry.querySelector('.lab-values').value.trim();
|
||||
if (vals) {
|
||||
labs.push({ date: entry.querySelector('.lab-date').value || '', values: vals });
|
||||
}
|
||||
});
|
||||
|
||||
if (notes.length === 0 && !edNote && !hAndP) {
|
||||
showToast('Add at least one note', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showBusy('Generating hospital course...');
|
||||
|
||||
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||
|
||||
memoriesPromise.then(function(memCtx) {
|
||||
return fetch('/api/generate-hospital-course', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
notes: notes,
|
||||
edNote: edNote,
|
||||
hAndP: hAndP,
|
||||
labs: labs,
|
||||
patientAge: document.getElementById('hc-age').value,
|
||||
patientGender: document.getElementById('hc-gender').value,
|
||||
pmh: document.getElementById('hc-pmh').value,
|
||||
setting: document.getElementById('hc-setting').value,
|
||||
los: document.getElementById('hc-los').value,
|
||||
formatPreference: document.getElementById('hc-format').value,
|
||||
additionalInstructions: document.getElementById('hc-instructions').value,
|
||||
physicianMemories: memCtx || null,
|
||||
model: getSelectedModel()
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
setOutputText(courseText, data.hospitalCourse);
|
||||
// Store source material for refine
|
||||
var hcSource = [];
|
||||
if (hAndP) hcSource.push('H&P (' + (hAndP.date || '') + '): ' + hAndP.content);
|
||||
if (edNote) hcSource.push('ED NOTE (' + (edNote.date || '') + '): ' + edNote.content + (edNote.labs ? '\nLabs: ' + edNote.labs : ''));
|
||||
notes.forEach(function(n) { hcSource.push('NOTE (' + (n.date || '') + ' ' + (n.type || '') + '): ' + n.content); });
|
||||
if (labs) hcSource.push('LABS: ' + labs);
|
||||
storeSourceContext('hc-course-text', hcSource.join('\n\n'));
|
||||
formatTag.textContent = data.format || '';
|
||||
modelTag.textContent = (data.model || '').split('/').pop();
|
||||
outputCard.classList.remove('hidden');
|
||||
clarifyOutput.classList.add('hidden');
|
||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||
showToast('Hospital course generated!', 'success');
|
||||
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('hc-course-text', data.hospitalCourse, 'hospital', document.getElementById('hc-age').value, document.getElementById('hc-setting').value);
|
||||
} else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
// Refine, Shorten, Clarify
|
||||
document.getElementById('hc-refine-btn').addEventListener('click', function() { refineDocument('hc-course-text', 'hc-refine-input'); });
|
||||
document.getElementById('hc-shorten-btn').addEventListener('click', function() { shortenDocument('hc-course-text'); });
|
||||
|
||||
document.getElementById('hc-clarify-btn').addEventListener('click', function() {
|
||||
var doc = courseText.innerText.trim();
|
||||
if (!doc) { showToast('Generate first', 'error'); return; }
|
||||
|
||||
showBusy('Checking for missing info...');
|
||||
|
||||
fetch('/api/clarify', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ document: doc, context: 'hospital course', model: getSelectedModel() })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
setOutputText(clarifyOutput, data.questions);
|
||||
clarifyOutput.classList.remove('hidden');
|
||||
showToast('Review missing information below', 'info');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
// Register load handler for saved encounters
|
||||
window.registerEncounterLoadHandler('hospital', function(enc) {
|
||||
if (enc.generated_note) {
|
||||
var courseText = document.getElementById('hc-course-text');
|
||||
var outputCard = document.getElementById('hc-output');
|
||||
if (courseText) courseText.textContent = enc.generated_note;
|
||||
if (outputCard) outputCard.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Hospital Course module loaded');
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,241 +0,0 @@
|
|||
(function() {
|
||||
var _inited = false;
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'encounter' || _inited) return;
|
||||
_inited = true;
|
||||
var recordBtn = document.getElementById('enc-record-btn');
|
||||
var pauseBtn = document.getElementById('enc-pause-btn');
|
||||
var indicator = document.getElementById('enc-recording-indicator');
|
||||
var timerEl = document.getElementById('enc-timer');
|
||||
var transcript = document.getElementById('enc-transcript');
|
||||
var clearBtn = document.getElementById('enc-clear');
|
||||
var generateBtn = document.getElementById('enc-generate-btn');
|
||||
var outputCard = document.getElementById('enc-output');
|
||||
var hpiText = document.getElementById('enc-hpi-text');
|
||||
var modelTag = document.getElementById('enc-model-tag');
|
||||
|
||||
var stopBtn = document.getElementById('enc-stop-btn');
|
||||
var recorder = new AudioRecorder();
|
||||
recorder._module = 'encounter';
|
||||
var timer = createTimer(timerEl);
|
||||
var isRecording = false;
|
||||
var isPaused = false;
|
||||
var recognition = createSpeechRecognition();
|
||||
var finalText = '';
|
||||
var sessionFinals = '';
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
if (recognition) {
|
||||
recognition.onresult = function(e) {
|
||||
var interim = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) {
|
||||
var chunk = e.results[i][0].transcript + ' ';
|
||||
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
|
||||
sessionFinals += deduped;
|
||||
} else {
|
||||
interim = e.results[i][0].transcript;
|
||||
}
|
||||
}
|
||||
var combined = finalText + sessionFinals;
|
||||
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
|
||||
};
|
||||
recognition.onend = function() {
|
||||
finalText += sessionFinals;
|
||||
sessionFinals = '';
|
||||
if (isRecording && !isPaused) try { recognition.start(); } catch(e) {}
|
||||
};
|
||||
recognition.onerror = function(e) {
|
||||
if (e.error === 'no-speech' || e.error === 'aborted') return;
|
||||
console.warn('[SpeechRecognition] error:', e.error);
|
||||
};
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', function() {
|
||||
if (!isRecording) {
|
||||
finalText = '';
|
||||
sessionFinals = '';
|
||||
recorder.start().then(function() {
|
||||
isRecording = true;
|
||||
isPaused = false;
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.style.display = 'none';
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
if (stopBtn) stopBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
timer.start();
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
if (typeof nativeHaptic === 'function') nativeHaptic('heavy');
|
||||
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(true);
|
||||
if (typeof nativeStartRecordingService === 'function') nativeStartRecordingService();
|
||||
showToast('Recording started', 'info');
|
||||
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'enc' } }));
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
isRecording = false;
|
||||
isPaused = false;
|
||||
var dur = timer.stop();
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.style.display = '';
|
||||
recordBtn.querySelector('span').textContent = 'Start Recording';
|
||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; pauseBtn.classList.remove('btn-primary'); pauseBtn.classList.add('btn-ghost'); }
|
||||
if (stopBtn) stopBtn.classList.add('hidden');
|
||||
indicator.classList.add('hidden');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
if (typeof nativeHaptic === 'function') nativeHaptic('medium');
|
||||
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(false);
|
||||
if (typeof nativeStopRecordingService === 'function') nativeStopRecordingService();
|
||||
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'enc' } }));
|
||||
|
||||
// If no server transcription API, use live transcript directly (no upload)
|
||||
var liveText = (finalText + sessionFinals).trim();
|
||||
if (window._transcribeAvailable === false) {
|
||||
recorder.stop().then(function() {});
|
||||
if (liveText) transcript.textContent = liveText;
|
||||
showToast('Using browser speech recognition (no transcription API configured)', 'info');
|
||||
} else {
|
||||
showBusy('Transcribing...');
|
||||
recorder.stop().then(function(blob) {
|
||||
if (!blob || blob.size === 0) { hideBusy(); if (liveText) transcript.textContent = liveText; return; }
|
||||
if (blob.size > 24 * 1024 * 1024) {
|
||||
hideBusy();
|
||||
transcript.textContent = liveText;
|
||||
showToast('Recording too large for AI transcription — using live transcript', 'info');
|
||||
return;
|
||||
}
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
||||
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
|
||||
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Failed', 'error'); }
|
||||
});
|
||||
}).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast(err.message, 'error'); });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Dedicated stop button (always visible during recording)
|
||||
if (stopBtn) {
|
||||
stopBtn.addEventListener('click', function() {
|
||||
if (!isRecording) return;
|
||||
// Temporarily show recordBtn so .click() triggers the stop flow
|
||||
recordBtn.style.display = '';
|
||||
recordBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Pause / Resume
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!isRecording) return;
|
||||
if (!isPaused) {
|
||||
// Pause: try MediaRecorder.pause(), fall back to stop+accumulate
|
||||
if (recorder.mediaRecorder) {
|
||||
try {
|
||||
if (typeof recorder.mediaRecorder.pause === 'function' && recorder.mediaRecorder.state === 'recording') {
|
||||
recorder.mediaRecorder.pause();
|
||||
}
|
||||
} catch(e) { console.warn('[Rec] Pause not supported:', e.message); }
|
||||
}
|
||||
timer.stop();
|
||||
isPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
pauseBtn.classList.add('btn-primary');
|
||||
pauseBtn.classList.remove('btn-ghost');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
showToast('Recording paused', 'info');
|
||||
} else {
|
||||
// Resume: try MediaRecorder.resume(), fall back to just restarting recognition
|
||||
if (recorder.mediaRecorder) {
|
||||
try {
|
||||
if (typeof recorder.mediaRecorder.resume === 'function' && recorder.mediaRecorder.state === 'paused') {
|
||||
recorder.mediaRecorder.resume();
|
||||
} else if (recorder.mediaRecorder.state === 'inactive') {
|
||||
// MediaRecorder was stopped (browser killed it) — restart it on the same stream
|
||||
if (recorder.stream && recorder.stream.active) {
|
||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
recorder.mediaRecorder = new MediaRecorder(recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recorder.chunks.push(e.data); };
|
||||
recorder.mediaRecorder.start(1000);
|
||||
}
|
||||
}
|
||||
} catch(e) { console.warn('[Rec] Resume error:', e.message); }
|
||||
}
|
||||
timer.resume();
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
showToast('Recording resumed', 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearBtn.addEventListener('click', function() {
|
||||
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
|
||||
window._savedEncId_encounter = null;
|
||||
var labelEl = document.getElementById('enc-label');
|
||||
if (labelEl) labelEl.value = '';
|
||||
var refineEl = document.getElementById('enc-refine-input');
|
||||
if (refineEl) refineEl.value = '';
|
||||
});
|
||||
|
||||
generateBtn.addEventListener('click', function() {
|
||||
var text = transcript.innerText.trim();
|
||||
if (!text) { showToast('No transcript', 'error'); return; }
|
||||
showBusy('Generating HPI...');
|
||||
|
||||
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||
|
||||
memoriesPromise.then(function(memCtx) {
|
||||
return fetch('/api/generate-hpi-encounter', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
transcript: text,
|
||||
patientAge: document.getElementById('enc-age').value,
|
||||
patientGender: document.getElementById('enc-gender').value,
|
||||
setting: document.getElementById('enc-setting').value,
|
||||
physicianMemories: memCtx || null,
|
||||
model: getSelectedModel()
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
setOutputText(hpiText, data.hpi);
|
||||
storeSourceContext('enc-hpi-text', transcript.innerText.trim());
|
||||
if (typeof trackAIOutput === 'function') trackAIOutput('enc-hpi-text', data.hpi);
|
||||
modelTag.textContent = (data.model || '').split('/').pop();
|
||||
outputCard.classList.remove('hidden');
|
||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||
showToast('HPI generated!', 'success');
|
||||
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('enc-hpi-text', data.hpi, 'hpi', document.getElementById('enc-age').value, document.getElementById('enc-setting').value);
|
||||
} else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
// Refine & Shorten
|
||||
document.getElementById('enc-refine-btn').addEventListener('click', function() { refineDocument('enc-hpi-text', 'enc-refine-input'); });
|
||||
document.getElementById('enc-shorten-btn').addEventListener('click', function() { shortenDocument('enc-hpi-text'); });
|
||||
|
||||
// Register load handler for resuming saved encounters
|
||||
if (typeof registerEncounterLoadHandler === 'function') {
|
||||
registerEncounterLoadHandler('encounter', function(enc) {
|
||||
if (enc.transcript) transcript.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
hpiText.textContent = enc.generated_note;
|
||||
outputCard.classList.remove('hidden');
|
||||
}
|
||||
try { var pd = JSON.parse(enc.partial_data || '{}'); if (pd.age) document.getElementById('enc-age').value = pd.age; if (pd.gender) document.getElementById('enc-gender').value = pd.gender; } catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Encounter module loaded');
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
// ============================================================
|
||||
// MEMORIES.JS — User template/memory storage + settings UI
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
var _memories = [];
|
||||
var _editingId = null;
|
||||
|
||||
var CATEGORY_LABELS = {
|
||||
physical_exam: 'Physical Exam',
|
||||
ros: 'Review of Systems',
|
||||
encounter_format: 'Encounter Format',
|
||||
family_history: 'Family History',
|
||||
assessment_plan: 'Assessment & Plan',
|
||||
template_soap: 'SOAP Template',
|
||||
template_hpi: 'HPI Template',
|
||||
template_wellvisit: 'Well Visit Template',
|
||||
template_sickvisit: 'Sick Visit Template',
|
||||
custom: 'Custom'
|
||||
};
|
||||
|
||||
// Correction categories (hidden from manual editing, managed by correction tracker)
|
||||
var CORRECTION_LABELS = {
|
||||
correction_soap: 'SOAP Correction',
|
||||
correction_hpi: 'HPI Correction',
|
||||
correction_encounter: 'Encounter Correction',
|
||||
correction_wellvisit: 'Well Visit Correction',
|
||||
correction_sickvisit: 'Sick Visit Correction'
|
||||
};
|
||||
|
||||
// ── Load memories ────────────────────────────────────────────────────────
|
||||
|
||||
function loadMemories() {
|
||||
fetch('/api/memories', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
_memories = data.memories || [];
|
||||
renderMemoryList();
|
||||
})
|
||||
.catch(function(err) { console.error('[Memories] Load failed:', err); });
|
||||
}
|
||||
|
||||
function renderMemoryList() {
|
||||
var container = document.getElementById('mem-list');
|
||||
if (!container) return;
|
||||
// Separate templates from corrections
|
||||
var templates = _memories.filter(function(m) { return !m.category.startsWith('correction_'); });
|
||||
var corrections = _memories.filter(function(m) { return m.category.startsWith('correction_'); });
|
||||
// Render corrections list
|
||||
renderCorrectionsList(corrections);
|
||||
if (templates.length === 0) {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No templates saved yet. Add one above.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = templates.map(function(m) {
|
||||
var catLabel = CATEGORY_LABELS[m.category] || m.category;
|
||||
var preview = (m.content || '').substring(0, 100).replace(/\n/g, ' ');
|
||||
return '<div class="mem-item" data-id="' + m.id + '">' +
|
||||
'<div class="mem-item-info">' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
|
||||
'<span class="mem-item-name">' + esc(m.name) + '</span>' +
|
||||
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="mem-item-preview">' + esc(preview) + (m.content && m.content.length > 100 ? '...' : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-ghost mem-edit-btn" data-id="' + m.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
|
||||
'<button class="btn-sm btn-ghost mem-delete-btn" data-id="' + m.id + '" style="color:var(--red);" title="Delete"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.mem-edit-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { editMemory(parseInt(btn.dataset.id)); });
|
||||
});
|
||||
container.querySelectorAll('.mem-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { deleteMemory(parseInt(btn.dataset.id)); });
|
||||
});
|
||||
}
|
||||
|
||||
function renderCorrectionsList(corrections) {
|
||||
var container = document.getElementById('corrections-list');
|
||||
if (!container) return;
|
||||
if (corrections.length === 0) {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No corrections yet. Edit AI-generated notes and save to start learning.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = corrections.map(function(m) {
|
||||
var catLabel = CORRECTION_LABELS[m.category] || m.category;
|
||||
var preview = (m.name || '').replace(/\n/g, ' ');
|
||||
// Parse original and corrected from content
|
||||
var parts = parseCorrection(m.content || '');
|
||||
var date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
|
||||
return '<div class="mem-item" style="flex-direction:column;align-items:stretch;cursor:pointer;" data-id="' + m.id + '">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;" class="correction-header" data-toggle="' + m.id + '">' +
|
||||
'<i class="fas fa-chevron-right correction-arrow" id="arrow-' + m.id + '" style="font-size:10px;color:var(--g400);transition:transform 0.2s;"></i>' +
|
||||
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
|
||||
'<span style="flex:1;font-size:12px;color:var(--g600);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(preview) + '</span>' +
|
||||
'<span style="font-size:11px;color:var(--g400);flex-shrink:0;">' + date + '</span>' +
|
||||
'<button class="btn-sm btn-ghost correction-delete-btn" data-id="' + m.id + '" style="color:var(--red);flex-shrink:0;" title="Delete"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="correction-detail hidden" id="detail-' + m.id + '" style="margin-top:8px;padding:8px 12px;background:var(--g50);border-radius:6px;font-size:12px;line-height:1.6;">' +
|
||||
'<div style="margin-bottom:8px;">' +
|
||||
'<div style="font-weight:600;color:var(--red);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Original (AI generated):</div>' +
|
||||
'<div style="color:var(--g600);white-space:pre-wrap;font-family:inherit;">' + esc(parts.original) + '</div>' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<div style="font-weight:600;color:var(--green);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Corrected to:</div>' +
|
||||
'<div style="color:var(--g800);white-space:pre-wrap;font-family:inherit;">' + esc(parts.corrected) + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// Toggle expand/collapse
|
||||
container.querySelectorAll('.correction-header').forEach(function(header) {
|
||||
header.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.correction-delete-btn')) return;
|
||||
var id = header.dataset.toggle;
|
||||
var detail = document.getElementById('detail-' + id);
|
||||
var arrow = document.getElementById('arrow-' + id);
|
||||
if (detail) {
|
||||
detail.classList.toggle('hidden');
|
||||
if (arrow) arrow.style.transform = detail.classList.contains('hidden') ? '' : 'rotate(90deg)';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.correction-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
deleteMemory(parseInt(btn.dataset.id));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseCorrection(content) {
|
||||
var original = '';
|
||||
var corrected = '';
|
||||
var idx = content.indexOf('\nCORRECTED TO: ');
|
||||
if (idx !== -1) {
|
||||
original = content.substring(0, idx).replace(/^ORIGINAL:\s*/i, '');
|
||||
corrected = content.substring(idx + '\nCORRECTED TO: '.length);
|
||||
} else {
|
||||
original = content;
|
||||
}
|
||||
return { original: original.trim(), corrected: corrected.trim() };
|
||||
}
|
||||
|
||||
// ── Save memory ──────────────────────────────────────────────────────────
|
||||
|
||||
function saveMemory() {
|
||||
var name = document.getElementById('mem-name').value.trim();
|
||||
var category = document.getElementById('mem-category').value;
|
||||
var content = document.getElementById('mem-content').value.trim();
|
||||
|
||||
if (!name) { showToast('Enter a template name', 'error'); return; }
|
||||
if (!content) { showToast('Enter template content', 'error'); return; }
|
||||
|
||||
var url = _editingId ? '/api/memories/' + _editingId : '/api/memories';
|
||||
var method = _editingId ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ name: name, category: category, content: content })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast(_editingId ? 'Template updated' : 'Template saved', 'success');
|
||||
cancelEdit();
|
||||
loadMemories();
|
||||
} else {
|
||||
showToast(data.error || 'Save failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
function editMemory(id) {
|
||||
var mem = _memories.find(function(m) { return m.id === id; });
|
||||
if (!mem) return;
|
||||
_editingId = id;
|
||||
document.getElementById('mem-name').value = mem.name;
|
||||
document.getElementById('mem-category').value = mem.category;
|
||||
document.getElementById('mem-content').value = mem.content;
|
||||
|
||||
var btn = document.getElementById('btn-mem-save');
|
||||
if (btn) btn.innerHTML = '<i class="fas fa-check"></i> Update Template';
|
||||
|
||||
// Add cancel button if not present
|
||||
if (!document.getElementById('btn-mem-cancel')) {
|
||||
var cancel = document.createElement('button');
|
||||
cancel.id = 'btn-mem-cancel';
|
||||
cancel.className = 'btn-sm btn-ghost';
|
||||
cancel.innerHTML = '<i class="fas fa-times"></i> Cancel';
|
||||
cancel.style.marginLeft = '6px';
|
||||
cancel.addEventListener('click', cancelEdit);
|
||||
if (btn && btn.parentNode) btn.parentNode.appendChild(cancel);
|
||||
}
|
||||
document.getElementById('mem-name').focus();
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
_editingId = null;
|
||||
document.getElementById('mem-name').value = '';
|
||||
document.getElementById('mem-content').value = '';
|
||||
var btn = document.getElementById('btn-mem-save');
|
||||
if (btn) btn.innerHTML = '<i class="fas fa-plus"></i> Add Template';
|
||||
var cancel = document.getElementById('btn-mem-cancel');
|
||||
if (cancel) cancel.remove();
|
||||
}
|
||||
|
||||
function deleteMemory(id) {
|
||||
var mem = _memories.find(function(m) { return m.id === id; });
|
||||
showConfirm('Delete template "' + (mem ? mem.name : '') + '"?', function() {
|
||||
fetch('/api/memories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('Template deleted', 'info'); loadMemories(); }
|
||||
else showToast(data.error || 'Delete failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Delete failed', 'error'); });
|
||||
}, { danger: true, confirmText: 'Delete' });
|
||||
}
|
||||
|
||||
// ── Event wiring ─────────────────────────────────────────────────────────
|
||||
|
||||
// Expose so auth.js can call on settings tab navigation
|
||||
window.loadMemories = loadMemories;
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
// Settings button or settings tab — load memories
|
||||
if (e.target.closest('#btn-settings') || (e.target.closest('.tab-btn') && e.target.closest('.tab-btn[data-tab="settings"]'))) {
|
||||
setTimeout(loadMemories, 100);
|
||||
}
|
||||
// Save memory button
|
||||
if (e.target.closest('#btn-mem-save')) {
|
||||
saveMemory();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Expose context for AI generation ────────────────────────────────────
|
||||
|
||||
// Call this before any AI generation to get user memory context.
|
||||
// Always fetches from the API so it works even if _memories cache is empty.
|
||||
window.getUserMemoryContext = function() {
|
||||
return fetch('/api/memories/context', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) { return data.success ? (data.context || '') : ''; })
|
||||
.catch(function() { return ''; });
|
||||
};
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
console.log('✅ Memories module loaded');
|
||||
|
||||
})();
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
(function() {
|
||||
var _inited = false;
|
||||
var MILESTONES_DATA = {}; // Will be loaded from API
|
||||
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'wellvisit' || _inited) return;
|
||||
_inited = true;
|
||||
initMilestones();
|
||||
});
|
||||
|
||||
function initMilestones() {
|
||||
var ageSelect = document.getElementById('ms-age-group');
|
||||
var checklist = document.getElementById('milestone-checklist');
|
||||
var actionsBar = document.getElementById('milestone-actions');
|
||||
var allYesBtn = document.getElementById('ms-all-yes');
|
||||
var allClearBtn = document.getElementById('ms-all-clear');
|
||||
var generateBtn = document.getElementById('ms-generate-btn');
|
||||
var outputCard = document.getElementById('ms-output');
|
||||
var summaryBar = document.getElementById('ms-summary-bar');
|
||||
var narrativeText = document.getElementById('ms-narrative-text');
|
||||
var modelTag = document.getElementById('ms-model-tag');
|
||||
|
||||
var state = {};
|
||||
|
||||
// Load milestones from API (database), fallback to static data if empty
|
||||
fetch('/api/milestones-data', {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success && data.milestones && Object.keys(data.milestones).length > 0) {
|
||||
// Database has milestones - use them
|
||||
MILESTONES_DATA = data.milestones;
|
||||
populateAgeGroups();
|
||||
} else {
|
||||
// Database empty - fallback to static data
|
||||
console.log('[Milestones] Database empty, using static data');
|
||||
if (typeof window.MILESTONES_DATA_STATIC !== 'undefined') {
|
||||
MILESTONES_DATA = window.MILESTONES_DATA_STATIC;
|
||||
populateAgeGroups();
|
||||
} else {
|
||||
showToast('Milestones data not available', 'error');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[Milestones] Load error, falling back to static:', err);
|
||||
// Fallback to static data on API error
|
||||
if (typeof window.MILESTONES_DATA_STATIC !== 'undefined') {
|
||||
MILESTONES_DATA = window.MILESTONES_DATA_STATIC;
|
||||
populateAgeGroups();
|
||||
} else {
|
||||
showToast('Failed to load milestones: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
function populateAgeGroups() {
|
||||
// Populate age group dropdown
|
||||
if (ageSelect && Object.keys(MILESTONES_DATA).length > 0) {
|
||||
ageSelect.innerHTML = '<option value="">Select age group</option>';
|
||||
Object.keys(MILESTONES_DATA).forEach(function(age) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = age;
|
||||
opt.textContent = age;
|
||||
ageSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ageSelect.addEventListener('change', function() {
|
||||
var age = ageSelect.value;
|
||||
state = {};
|
||||
outputCard.classList.add('hidden');
|
||||
if (!age || !MILESTONES_DATA[age]) {
|
||||
checklist.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">Select an age group.</p>';
|
||||
actionsBar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
renderChecklist(MILESTONES_DATA[age]);
|
||||
actionsBar.style.display = 'flex';
|
||||
});
|
||||
|
||||
function safeId(s) { return s.replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||
|
||||
function renderChecklist(data) {
|
||||
checklist.innerHTML = '';
|
||||
Object.keys(data).forEach(function(domain) {
|
||||
var items = data[domain];
|
||||
var config = DOMAIN_CONFIG[domain] || { icon: '📋', css: '' };
|
||||
var section = document.createElement('div');
|
||||
section.className = 'domain-section ' + config.css;
|
||||
|
||||
var html = '<div class="domain-header"><span>' + config.icon + '</span><span>' + domain +
|
||||
'</span><span class="badge" id="badge-' + safeId(domain) + '">' + items.length + ' items</span></div><div class="ms-items">';
|
||||
|
||||
items.forEach(function(m, idx) {
|
||||
var id = domain + '-' + idx;
|
||||
state[id] = { domain: domain, milestone: m, status: null };
|
||||
html += '<div class="ms-item" id="item-' + safeId(id) + '">' +
|
||||
'<div class="ms-toggle">' +
|
||||
'<button class="toggle-btn" data-id="' + id + '" data-action="yes">✓</button>' +
|
||||
'<button class="toggle-btn" data-id="' + id + '" data-action="no">✗</button>' +
|
||||
'</div><span class="ms-text">' + m + '</span></div>';
|
||||
});
|
||||
|
||||
section.innerHTML = html + '</div>';
|
||||
checklist.appendChild(section);
|
||||
});
|
||||
|
||||
checklist.querySelectorAll('.toggle-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { handleToggle(btn.getAttribute('data-id'), btn.getAttribute('data-action')); });
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggle(id, action) {
|
||||
var sid = safeId(id);
|
||||
var item = document.getElementById('item-' + sid);
|
||||
var yesBtn = item.querySelector('[data-action="yes"]');
|
||||
var noBtn = item.querySelector('[data-action="no"]');
|
||||
|
||||
if (action === 'yes') {
|
||||
if (state[id].status === 'yes') { state[id].status = null; yesBtn.classList.remove('yes-on'); item.classList.remove('is-yes','is-no'); }
|
||||
else { state[id].status = 'yes'; yesBtn.classList.add('yes-on'); noBtn.classList.remove('no-on'); item.classList.add('is-yes'); item.classList.remove('is-no'); }
|
||||
} else {
|
||||
if (state[id].status === 'no') { state[id].status = null; noBtn.classList.remove('no-on'); item.classList.remove('is-yes','is-no'); }
|
||||
else { state[id].status = 'no'; noBtn.classList.add('no-on'); yesBtn.classList.remove('yes-on'); item.classList.remove('is-yes'); item.classList.add('is-no'); }
|
||||
}
|
||||
updateBadges();
|
||||
}
|
||||
|
||||
function updateBadges() {
|
||||
var counts = {};
|
||||
Object.keys(state).forEach(function(id) {
|
||||
var d = state[id].domain;
|
||||
if (!counts[d]) counts[d] = { total: 0, yes: 0, no: 0 };
|
||||
counts[d].total++;
|
||||
if (state[id].status === 'yes') counts[d].yes++;
|
||||
if (state[id].status === 'no') counts[d].no++;
|
||||
});
|
||||
Object.keys(counts).forEach(function(d) {
|
||||
var b = document.getElementById('badge-' + safeId(d));
|
||||
if (b) { var c = counts[d]; b.textContent = (c.yes + c.no) > 0 ? c.yes + '✓ ' + c.no + '✗ / ' + c.total : c.total + ' items'; }
|
||||
});
|
||||
}
|
||||
|
||||
allYesBtn.addEventListener('click', function() {
|
||||
Object.keys(state).forEach(function(id) {
|
||||
state[id].status = 'yes';
|
||||
var item = document.getElementById('item-' + safeId(id));
|
||||
if (item) { item.querySelector('[data-action="yes"]').classList.add('yes-on'); item.querySelector('[data-action="no"]').classList.remove('no-on'); item.classList.add('is-yes'); item.classList.remove('is-no'); }
|
||||
});
|
||||
updateBadges();
|
||||
});
|
||||
|
||||
allClearBtn.addEventListener('click', function() {
|
||||
Object.keys(state).forEach(function(id) {
|
||||
state[id].status = null;
|
||||
var item = document.getElementById('item-' + safeId(id));
|
||||
if (item) { item.querySelector('[data-action="yes"]').classList.remove('yes-on'); item.querySelector('[data-action="no"]').classList.remove('no-on'); item.classList.remove('is-yes','is-no'); }
|
||||
});
|
||||
updateBadges();
|
||||
outputCard.classList.add('hidden');
|
||||
});
|
||||
|
||||
generateBtn.addEventListener('click', function() {
|
||||
if (!ageSelect.value) { showToast('Select age group', 'error'); return; }
|
||||
var assessed = Object.values(state).filter(function(m) { return m.status !== null; });
|
||||
if (assessed.length === 0) { showToast('Assess at least one milestone', 'error'); return; }
|
||||
|
||||
showBusy('Generating...');
|
||||
|
||||
fetch('/api/generate-milestone-narrative', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
milestones: Object.values(state),
|
||||
ageGroup: ageSelect.value,
|
||||
patientAge: document.getElementById('ms-age').value,
|
||||
patientGender: document.getElementById('ms-gender').value,
|
||||
format: document.getElementById('ms-format').value,
|
||||
model: getSelectedModel()
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
setOutputText(narrativeText, data.narrative);
|
||||
modelTag.textContent = (data.model || '').split('/').pop();
|
||||
if (data.summary) {
|
||||
summaryBar.innerHTML = '<div class="stat"><span class="dot dot-green"></span> Achieved: ' + data.summary.achieved + '</div>' +
|
||||
'<div class="stat"><span class="dot dot-red"></span> Not Yet: ' + data.summary.notAchieved + '</div>' +
|
||||
'<div class="stat"><span class="dot dot-gray"></span> Not Assessed: ' + data.summary.notAssessed + ' (omitted)</div>';
|
||||
summaryBar.classList.remove('hidden');
|
||||
}
|
||||
outputCard.classList.remove('hidden');
|
||||
document.getElementById('ms-quick-summary').classList.add('hidden');
|
||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||
showToast('Generated!', 'success');
|
||||
} else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
// 3-sentence summary
|
||||
document.getElementById('ms-quick-summary-btn').addEventListener('click', function() {
|
||||
var narrative = narrativeText.innerText.trim();
|
||||
if (!narrative) { showToast('Generate narrative first', 'error'); return; }
|
||||
|
||||
var btn = document.getElementById('ms-quick-summary-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Summarizing...';
|
||||
|
||||
fetch('/api/generate-milestone-summary', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
narrative: narrative,
|
||||
ageGroup: ageSelect.value,
|
||||
patientAge: document.getElementById('ms-age').value,
|
||||
patientGender: document.getElementById('ms-gender').value,
|
||||
model: getSelectedModel()
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-compress"></i> Regenerate Summary';
|
||||
if (data.success) {
|
||||
setOutputText('ms-summary-text', data.summary);
|
||||
document.getElementById('ms-quick-summary').classList.remove('hidden');
|
||||
showToast('Summary generated!', 'success');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-compress"></i> 3-Sentence Summary'; showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
console.log('✅ Milestones module loaded');
|
||||
} // end initMilestones
|
||||
})();
|
||||
|
|
@ -1,719 +0,0 @@
|
|||
// ============================================================
|
||||
// DEVELOPMENTAL MILESTONES DATA
|
||||
// Sources: AAP Bright Futures, Nelson Textbook of Pediatrics
|
||||
// THIS MODULE IS COMPLETELY SEPARATE FROM HPI
|
||||
// ============================================================
|
||||
|
||||
// Static fallback data - used when database is empty
|
||||
var MILESTONES_DATA_STATIC = {
|
||||
|
||||
"Newborn / 1 month": {
|
||||
"Gross Motor": [
|
||||
"Moves arms and legs equally",
|
||||
"Lifts head briefly when on tummy (prone)",
|
||||
"Strong flexion posture (arms and legs tucked)",
|
||||
"Turns head side to side when lying on back"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Hands mostly fisted",
|
||||
"Strong grasp reflex when palm is touched",
|
||||
"Brings hands near face"
|
||||
],
|
||||
"Language": [
|
||||
"Cries to express needs",
|
||||
"Startles or quiets to sounds",
|
||||
"Makes brief throaty sounds"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Recognizes caregiver voice",
|
||||
"Briefly fixates on faces at close range (8-12 inches)",
|
||||
"Calms when held or hears familiar voice",
|
||||
"Shows brief alert periods"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Focuses on faces briefly",
|
||||
"Follows objects briefly to midline",
|
||||
"Prefers black and white or high-contrast patterns",
|
||||
"Responds to loud sounds (Moro reflex)"
|
||||
]
|
||||
},
|
||||
|
||||
"2 months": {
|
||||
"Gross Motor": [
|
||||
"Lifts head when prone (45 degrees)",
|
||||
"Holds head steady when held upright briefly",
|
||||
"Moves both arms and both legs",
|
||||
"Pushes up on tummy"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Opens hands briefly",
|
||||
"Holds rattle if placed in hand briefly",
|
||||
"Brings hands to midline"
|
||||
],
|
||||
"Language": [
|
||||
"Coos and makes gurgling sounds",
|
||||
"Makes sounds other than crying",
|
||||
"Turns head toward sounds"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Social smile (responsive to faces)",
|
||||
"Begins to self-soothe (hands to mouth)",
|
||||
"Tries to look at parent",
|
||||
"Calms when spoken to or picked up"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Pays attention to faces",
|
||||
"Begins to follow things with eyes",
|
||||
"Recognizes people at a distance"
|
||||
]
|
||||
},
|
||||
|
||||
"4 months": {
|
||||
"Gross Motor": [
|
||||
"Holds head steady unsupported",
|
||||
"Pushes up to elbows when on tummy",
|
||||
"May roll over tummy to back",
|
||||
"Pushes down on legs when feet on hard surface"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Reaches for toys with one hand",
|
||||
"Uses hands and eyes together",
|
||||
"Grasps and shakes hand toys"
|
||||
],
|
||||
"Language": [
|
||||
"Babbles with expression and copies sounds",
|
||||
"Cries differently for hunger pain and tiredness",
|
||||
"Makes vowel sounds ah eh oh"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Smiles spontaneously especially at people",
|
||||
"Likes to play and may cry when playing stops",
|
||||
"Copies movements and facial expressions"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Lets you know if happy or sad",
|
||||
"Follows moving things with eyes side to side",
|
||||
"Watches faces closely",
|
||||
"Recognizes familiar people at a distance"
|
||||
]
|
||||
},
|
||||
|
||||
"6 months": {
|
||||
"Gross Motor": [
|
||||
"Rolls over in both directions",
|
||||
"Begins to sit without support",
|
||||
"Supports weight on legs and might bounce",
|
||||
"Rocks back and forth on hands and knees"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Reaches for and grasps objects",
|
||||
"Transfers objects hand to hand",
|
||||
"Raking grasp to pick up objects",
|
||||
"Brings things to mouth"
|
||||
],
|
||||
"Language": [
|
||||
"Responds to own name",
|
||||
"Responds to sounds by making sounds",
|
||||
"Strings vowels together when babbling",
|
||||
"Begins consonant sounds m and b",
|
||||
"Makes sounds to show joy and displeasure"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Knows familiar faces and recognizes strangers",
|
||||
"Likes to play with others especially parents",
|
||||
"Responds to other peoples emotions",
|
||||
"Likes to look at self in mirror"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Looks around at things nearby",
|
||||
"Shows curiosity and tries to get things out of reach",
|
||||
"Begins to pass things from one hand to the other"
|
||||
]
|
||||
},
|
||||
|
||||
"9 months": {
|
||||
"Gross Motor": [
|
||||
"Stands holding on to support",
|
||||
"Can get into sitting position",
|
||||
"Sits without support",
|
||||
"Pulls to stand",
|
||||
"Crawls"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Pincer grasp developing (thumb and index finger)",
|
||||
"Transfers objects smoothly between hands",
|
||||
"Picks up small objects like cereal pieces",
|
||||
"Bangs two objects together"
|
||||
],
|
||||
"Language": [
|
||||
"Understands no",
|
||||
"Makes many different sounds like mamamama bababababa",
|
||||
"Copies sounds and gestures of others",
|
||||
"Uses fingers to point at things"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"May be afraid of strangers (stranger anxiety)",
|
||||
"May be clingy with familiar adults (separation anxiety)",
|
||||
"Has favorite toys",
|
||||
"Plays peek-a-boo"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Watches the path of something as it falls",
|
||||
"Looks for hidden things (object permanence developing)",
|
||||
"Puts things in mouth to explore",
|
||||
"Moves things smoothly between hands"
|
||||
]
|
||||
},
|
||||
|
||||
"12 months": {
|
||||
"Gross Motor": [
|
||||
"Pulls up to stand",
|
||||
"Walks holding on to furniture (cruising)",
|
||||
"May take a few steps without holding on",
|
||||
"May stand alone"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Neat pincer grasp (thumb and forefinger)",
|
||||
"Puts things in a container",
|
||||
"Takes things out of a container",
|
||||
"Lets go of things without help",
|
||||
"Pokes with index finger"
|
||||
],
|
||||
"Language": [
|
||||
"Says mama and dada with meaning",
|
||||
"Responds to simple spoken requests",
|
||||
"Uses simple gestures like waving bye-bye",
|
||||
"Says one to two other words",
|
||||
"Tries to say words you say"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Is shy or nervous with strangers",
|
||||
"Cries when mom or dad leaves",
|
||||
"Has favorite things and people",
|
||||
"Shows fear in some situations",
|
||||
"Hands you a book to hear a story"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Explores things by shaking banging throwing",
|
||||
"Finds hidden things easily",
|
||||
"Looks at right picture when it is named",
|
||||
"Copies gestures",
|
||||
"Starts to use things correctly like drinking from cup"
|
||||
]
|
||||
},
|
||||
|
||||
"15 months": {
|
||||
"Gross Motor": [
|
||||
"Walks independently",
|
||||
"May walk up steps with help",
|
||||
"Stoops to pick up toy and stands back up"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Stacks two blocks",
|
||||
"Scribbles with crayon",
|
||||
"Drinks from cup with some spilling",
|
||||
"Uses fingers to eat"
|
||||
],
|
||||
"Language": [
|
||||
"Says three to five words",
|
||||
"Follows one-step commands without gestures",
|
||||
"Points to show something interesting",
|
||||
"Points to ask for something"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Copies other children while playing",
|
||||
"Shows affection to familiar people",
|
||||
"Claps when excited",
|
||||
"Shows you objects they like"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Tries to use things the right way (phone cup book)",
|
||||
"Stacks at least two small objects",
|
||||
"Follows one-step directions without gestures"
|
||||
]
|
||||
},
|
||||
|
||||
"18 months": {
|
||||
"Gross Motor": [
|
||||
"Walks well independently",
|
||||
"Runs stiffly",
|
||||
"Walks up steps with hand held",
|
||||
"Pulls toys while walking",
|
||||
"Climbs on and off furniture without help"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Scribbles spontaneously",
|
||||
"Stacks three to four blocks",
|
||||
"Drinks from cup",
|
||||
"Eats with spoon (messy)",
|
||||
"Turns book pages two to three at a time"
|
||||
],
|
||||
"Language": [
|
||||
"Says ten to twenty or more single words",
|
||||
"Says and shakes head no",
|
||||
"Points to show someone what they want",
|
||||
"Points to one body part when asked",
|
||||
"Follows one-step verbal commands without gestures"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Likes to hand things to others as play",
|
||||
"May have temper tantrums",
|
||||
"Shows affection to familiar people",
|
||||
"Plays simple pretend like feeding a doll",
|
||||
"May cling to caregivers in new situations"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Knows what ordinary things are for (phone brush spoon)",
|
||||
"Points to get attention of others",
|
||||
"Shows interest in doll by pretending to feed",
|
||||
"Points to one body part when asked",
|
||||
"Scribbles on own"
|
||||
]
|
||||
},
|
||||
|
||||
"24 months": {
|
||||
"Gross Motor": [
|
||||
"Kicks a ball",
|
||||
"Runs",
|
||||
"Walks up and down stairs holding on",
|
||||
"Stands on tiptoe",
|
||||
"Throws ball overhand",
|
||||
"Begins to jump with both feet"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Stacks six or more blocks",
|
||||
"Turns book pages one at a time",
|
||||
"Turns door handles",
|
||||
"Copies a straight line",
|
||||
"Eats with spoon well"
|
||||
],
|
||||
"Language": [
|
||||
"Points to things in book when asked",
|
||||
"Says sentences with two to four words",
|
||||
"Follows simple two-step instructions",
|
||||
"Knows names of familiar people and body parts",
|
||||
"Uses more than fifty words",
|
||||
"Strangers understand about half of speech"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Copies others especially adults and older children",
|
||||
"Gets excited with other children",
|
||||
"Shows increasing independence",
|
||||
"Plays mainly beside other children (parallel play)",
|
||||
"Shows defiant behavior"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Finds things hidden under two to three covers",
|
||||
"Begins to sort shapes and colors",
|
||||
"Plays simple make-believe",
|
||||
"Builds tower of four or more blocks",
|
||||
"Follows two-step instructions"
|
||||
]
|
||||
},
|
||||
|
||||
"30 months": {
|
||||
"Gross Motor": [
|
||||
"Jumps with both feet off the ground",
|
||||
"Walks up stairs alternating feet with rail",
|
||||
"Runs well without falling",
|
||||
"Kicks ball forward"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Stacks eight or more blocks",
|
||||
"Copies a vertical line",
|
||||
"Turns pages one at a time",
|
||||
"Strings large beads"
|
||||
],
|
||||
"Language": [
|
||||
"Uses two to three word phrases routinely",
|
||||
"Uses plurals (dogs cats)",
|
||||
"Knows at least one color",
|
||||
"Uses I me you",
|
||||
"Vocabulary over 200 words"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Plays alongside progressing to interactive play",
|
||||
"Shows concern for crying friend",
|
||||
"Shows wide range of emotions",
|
||||
"Takes turns in games with help"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Plays make-believe with dolls animals people",
|
||||
"Does puzzles with three to four pieces",
|
||||
"Understands concept of two",
|
||||
"Copies a circle by age 3"
|
||||
]
|
||||
},
|
||||
|
||||
"36 months": {
|
||||
"Gross Motor": [
|
||||
"Climbs well",
|
||||
"Runs easily",
|
||||
"Pedals a tricycle",
|
||||
"Walks up and down stairs one foot per step",
|
||||
"Jumps forward"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Copies a circle",
|
||||
"Builds towers of more than nine blocks",
|
||||
"Turns rotating handles like doorknobs",
|
||||
"Uses scissors to snip"
|
||||
],
|
||||
"Language": [
|
||||
"Carries on conversation using two to three sentences",
|
||||
"Names most familiar things",
|
||||
"Understands in on under",
|
||||
"Says first name age and sex",
|
||||
"Talks well enough for strangers to understand most of the time",
|
||||
"Follows two to three step instructions"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Copies adults and friends",
|
||||
"Takes turns in games",
|
||||
"Shows affection for friends without prompting",
|
||||
"Understands mine and his or hers",
|
||||
"Separates easily from parents"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Works toys with buttons levers and moving parts",
|
||||
"Plays make-believe with dolls animals people",
|
||||
"Does puzzles with three to four pieces",
|
||||
"Understands what two means",
|
||||
"Copies a circle with pencil or crayon"
|
||||
]
|
||||
},
|
||||
|
||||
"48 months": {
|
||||
"Gross Motor": [
|
||||
"Hops on one foot",
|
||||
"Catches a bounced ball most of the time",
|
||||
"Walks up and down stairs without support",
|
||||
"Kicks ball forward with force",
|
||||
"Stands on one foot for two or more seconds"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Draws a person with two to four body parts",
|
||||
"Copies a cross shape",
|
||||
"Uses scissors to cut along a line",
|
||||
"Prints some capital letters",
|
||||
"Stacks ten or more blocks"
|
||||
],
|
||||
"Language": [
|
||||
"Knows some basic rules of grammar like he and she",
|
||||
"Sings a song or says a poem from memory",
|
||||
"Tells stories",
|
||||
"Says first and last name",
|
||||
"Understands same and different",
|
||||
"Uses sentences with four or more words",
|
||||
"Speech is fully intelligible to strangers"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Enjoys doing new things",
|
||||
"Plays mom and dad (role playing)",
|
||||
"Is creative with make-believe play",
|
||||
"Would rather play with others than alone",
|
||||
"Cooperates with other children"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Names some colors and numbers",
|
||||
"Understands the idea of counting",
|
||||
"Starts to understand time concepts",
|
||||
"Remembers parts of a story",
|
||||
"Understands same and different",
|
||||
"Draws a person with two to four body parts"
|
||||
]
|
||||
},
|
||||
|
||||
"60 months": {
|
||||
"Gross Motor": [
|
||||
"Stands on one foot for ten or more seconds",
|
||||
"Hops and may skip",
|
||||
"Can do a somersault",
|
||||
"Swings and climbs",
|
||||
"Catches a small ball using hands only"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Copies a triangle and other shapes",
|
||||
"Draws a person with at least six body parts",
|
||||
"Prints some letters and numbers",
|
||||
"Uses fork and spoon well and may use knife",
|
||||
"Can dress and undress independently"
|
||||
],
|
||||
"Language": [
|
||||
"Speaks very clearly",
|
||||
"Tells a simple story using full sentences",
|
||||
"Uses future tense",
|
||||
"Says name and address",
|
||||
"Counts ten or more objects",
|
||||
"Uses five to eight word sentences"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Wants to please friends",
|
||||
"Wants to be like friends",
|
||||
"More likely to agree with rules",
|
||||
"Likes to sing dance and act",
|
||||
"Aware of gender",
|
||||
"Can tell real from make-believe"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Counts ten or more objects",
|
||||
"Names at least four colors correctly",
|
||||
"Better understands time concepts",
|
||||
"Knows about everyday things (money food)",
|
||||
"Draws a person with at least six body parts",
|
||||
"Prints some letters or numbers"
|
||||
]
|
||||
},
|
||||
|
||||
"6 years": {
|
||||
"Gross Motor": [
|
||||
"Rides a bicycle without training wheels",
|
||||
"Skips and runs with good coordination",
|
||||
"Kicks and catches a ball reliably",
|
||||
"Jumps rope",
|
||||
"Balances on one foot for several seconds"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Writes name and simple words legibly",
|
||||
"Colors within lines",
|
||||
"Cuts along straight and curved lines with scissors",
|
||||
"Ties shoelaces independently",
|
||||
"Draws a recognizable person with details"
|
||||
],
|
||||
"Language": [
|
||||
"Uses 5- to 8-word sentences routinely",
|
||||
"Reads simple words and short sentences",
|
||||
"Tells connected stories about events",
|
||||
"Understands and follows three-step instructions",
|
||||
"Articulates clearly; speech understood by all"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Prefers same-sex friends but plays with both",
|
||||
"Rule-oriented; upset when rules are broken",
|
||||
"Enjoys cooperative play and group games",
|
||||
"Begins to understand others' perspectives",
|
||||
"May show competitive behavior",
|
||||
"Seeks adult approval; eager to please"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Counts reliably to 100; adds and subtracts single digits",
|
||||
"Distinguishes left from right on self",
|
||||
"Understands concept of time (days weeks months)",
|
||||
"Names days of the week in order",
|
||||
"Classifies objects by two attributes (color and shape)",
|
||||
"Reads simple books with help"
|
||||
]
|
||||
},
|
||||
|
||||
"7 years": {
|
||||
"Gross Motor": [
|
||||
"Rides a bicycle with control and speed",
|
||||
"Swims basic strokes with instruction",
|
||||
"Performs organized sports skills (kicking passing)",
|
||||
"Skips smoothly and gallops",
|
||||
"Broad jumps and hops on either foot"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Writes letters and numbers with good legibility",
|
||||
"Draws detailed pictures with background",
|
||||
"Uses tools (ruler compass) with guidance",
|
||||
"Cuts complex shapes accurately",
|
||||
"Prints and begins to learn cursive"
|
||||
],
|
||||
"Language": [
|
||||
"Reads independently at grade level",
|
||||
"Explains the reason for rules and events",
|
||||
"Vocabulary of 2,000+ words in use",
|
||||
"Tells jokes and riddles; understands puns",
|
||||
"Distinguishes fantasy from reality verbally"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Increasing importance of peer acceptance",
|
||||
"Aware of self in relation to peers",
|
||||
"May be critical of self or others",
|
||||
"Better emotional regulation than at 6",
|
||||
"Begins to keep secrets",
|
||||
"Enjoys team activities and group projects"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Reads chapter books independently",
|
||||
"Adds and subtracts two-digit numbers",
|
||||
"Understands concept of multiplication",
|
||||
"Remembers the order of daily activities",
|
||||
"Can focus on tasks for 20–30 minutes",
|
||||
"Understands conservation of number and mass"
|
||||
]
|
||||
},
|
||||
|
||||
"8 years": {
|
||||
"Gross Motor": [
|
||||
"Participates in organized team sports",
|
||||
"Demonstrates smooth and rhythmic movement",
|
||||
"Increased stamina and speed",
|
||||
"Performs complex physical tasks (gymnastics swimming)",
|
||||
"Coordinates movements for ball sports"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Writes in cursive or print with ease",
|
||||
"Uses keyboard with increasing speed",
|
||||
"Constructs detailed models and crafts",
|
||||
"Draws in proportion with perspective",
|
||||
"Handles small tools (compass protractor) independently"
|
||||
],
|
||||
"Language": [
|
||||
"Reads fluently with comprehension",
|
||||
"Writes simple paragraphs and stories",
|
||||
"Understands figurative language (metaphors similes)",
|
||||
"Engages in debate and argument",
|
||||
"Extensive vocabulary; enjoys word games"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Strong peer relationships; friendship very important",
|
||||
"Understands fairness and reciprocity",
|
||||
"Increases independence from parents",
|
||||
"More aware of social norms and fitting in",
|
||||
"Can experience peer pressure",
|
||||
"Develops sense of personal competence"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Multiplication and division of single digits",
|
||||
"Reads and writes well independently",
|
||||
"Can focus on single task for 45–60 minutes",
|
||||
"Understands cause and effect in social situations",
|
||||
"Begins basic scientific reasoning",
|
||||
"Uses internet and technology with guidance"
|
||||
]
|
||||
},
|
||||
|
||||
"9 years": {
|
||||
"Gross Motor": [
|
||||
"Highly coordinated in sports and physical activities",
|
||||
"Sustained participation in team or individual sports",
|
||||
"Increasing strength and endurance",
|
||||
"Performs complex gymnastic or athletic maneuvers",
|
||||
"Competes in races or organized games"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Writes in legible cursive for extended periods",
|
||||
"Creates detailed artwork with perspective",
|
||||
"Proficient with tools requiring precision",
|
||||
"Types efficiently on keyboard",
|
||||
"Complex crafts (knitting sewing model-building)"
|
||||
],
|
||||
"Language": [
|
||||
"Reads chapter books independently for pleasure",
|
||||
"Writes multi-paragraph essays and reports",
|
||||
"Understands and uses complex grammar",
|
||||
"Can summarize and explain what was read",
|
||||
"Participates in formal discussions and debates"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Best-friend relationships central",
|
||||
"Sensitive to criticism from peers",
|
||||
"Increasing desire for privacy",
|
||||
"Understands others' complex feelings",
|
||||
"May show early concern about body image",
|
||||
"Appreciates humor and sarcasm"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Multiplies and divides multi-digit numbers",
|
||||
"Understands fractions and decimals",
|
||||
"Uses logical reasoning to solve problems",
|
||||
"Plans multi-step projects",
|
||||
"Aware of abstract concepts (justice democracy)",
|
||||
"Can memorize facts systematically"
|
||||
]
|
||||
},
|
||||
|
||||
"10 years": {
|
||||
"Gross Motor": [
|
||||
"Skilled and competitive in preferred sports",
|
||||
"Strong endurance for prolonged physical activity",
|
||||
"Good gross motor skills approaching adult patterns",
|
||||
"Enjoys recreational and competitive athletics",
|
||||
"Participates in organized leagues or clubs"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Near-adult level fine motor precision",
|
||||
"Plays musical instrument with practice",
|
||||
"Produces polished written and artistic work",
|
||||
"Efficient and comfortable with keyboard",
|
||||
"Complex craft work with minimal guidance"
|
||||
],
|
||||
"Language": [
|
||||
"Reading at or above grade level",
|
||||
"Writes organized multi-paragraph compositions",
|
||||
"Uses sophisticated vocabulary in writing and speech",
|
||||
"Can write a coherent argument or persuasive essay",
|
||||
"Understands complex figurative language"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Peer opinions very important; social conformity increases",
|
||||
"Beginning awareness of romantic attraction possible",
|
||||
"Understands nuance in social situations",
|
||||
"Stronger sense of personal identity",
|
||||
"May question family values or rules",
|
||||
"Can delay gratification effectively"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Beginning of formal operational thinking (Piaget)",
|
||||
"Understands fractions percentages and ratios",
|
||||
"Thinks about hypothetical situations",
|
||||
"Can plan and prioritize independently",
|
||||
"Understands multiple perspectives simultaneously",
|
||||
"Increasing interest in logic puzzles and strategy"
|
||||
]
|
||||
},
|
||||
|
||||
"11 years": {
|
||||
"Gross Motor": [
|
||||
"Physical activity preferences clearly established",
|
||||
"May be entering puberty; growth spurt possible",
|
||||
"Excellent coordination in practiced skills",
|
||||
"Varies widely by individual based on puberty timing",
|
||||
"Participates in physical education and sports"
|
||||
],
|
||||
"Fine Motor": [
|
||||
"Adult-level fine motor skills in practiced areas",
|
||||
"Skilled at digital technology use",
|
||||
"Sophisticated artwork and craftsmanship",
|
||||
"Writes fluidly and legibly for extended periods",
|
||||
"Performs complex manual tasks independently"
|
||||
],
|
||||
"Language": [
|
||||
"Reading comprehension approaching adult level",
|
||||
"Writes research reports and persuasive essays",
|
||||
"Understands sarcasm irony and double meaning",
|
||||
"Expresses nuanced opinions in writing and speech",
|
||||
"Vocabulary approaching 50,000+ words receptively"
|
||||
],
|
||||
"Social/Emotional": [
|
||||
"Peer relationships may become central to identity",
|
||||
"May experiment with social roles and identity",
|
||||
"Possible early signs of puberty-related mood variability",
|
||||
"Greater capacity for empathy and perspective-taking",
|
||||
"Interest in romantic relationships may emerge",
|
||||
"Begins to develop adult moral reasoning"
|
||||
],
|
||||
"Cognitive": [
|
||||
"Abstract reasoning develops steadily",
|
||||
"Can think through complex hypothetical problems",
|
||||
"Understands scientific method and experiment design",
|
||||
"Increased metacognition (thinking about thinking)",
|
||||
"Can plan and self-regulate over longer time horizons",
|
||||
"Interested in fairness justice and ethics"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var DOMAIN_CONFIG = {
|
||||
"Gross Motor": { icon: "🏃", css: "domain-gross-motor" },
|
||||
"Fine Motor": { icon: "✋", css: "domain-fine-motor" },
|
||||
"Language": { icon: "🗣️", css: "domain-language" },
|
||||
"Social/Emotional": { icon: "🤝", css: "domain-social" },
|
||||
"Cognitive": { icon: "🧠", css: "domain-cognitive" }
|
||||
};
|
||||
|
||||
// Expose as window global for fallback usage
|
||||
window.MILESTONES_DATA_STATIC = MILESTONES_DATA_STATIC;
|
||||
console.log('✅ Static milestones data loaded:', Object.keys(MILESTONES_DATA_STATIC).length, 'age groups');
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
(function() {
|
||||
var _inited = false;
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'settings' || _inited) return;
|
||||
_inited = true;
|
||||
function loadNextcloudStatus() {
|
||||
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var pathSection = document.getElementById('nc-webdav-path-section');
|
||||
if (data.user && data.user.nextcloud_url) {
|
||||
document.getElementById('nc-status').innerHTML = '✅ Connected to <strong>' + data.user.nextcloud_url + '</strong> as ' + data.user.nextcloud_user;
|
||||
document.getElementById('nc-url').value = data.user.nextcloud_url;
|
||||
document.getElementById('nc-user').value = data.user.nextcloud_user;
|
||||
document.getElementById('btn-nc-disconnect').classList.remove('hidden');
|
||||
if (pathSection) {
|
||||
pathSection.classList.remove('hidden');
|
||||
var pathEl = document.getElementById('nc-webdav-path');
|
||||
if (pathEl) pathEl.value = data.user.webdav_learning_path || '';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('nc-status').textContent = 'Not connected';
|
||||
document.getElementById('btn-nc-disconnect').classList.add('hidden');
|
||||
if (pathSection) pathSection.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.loadNextcloudStatus = loadNextcloudStatus;
|
||||
|
||||
document.getElementById('btn-nc-connect').addEventListener('click', function() {
|
||||
var url = document.getElementById('nc-url').value.trim().replace(/\/+$/, '');
|
||||
var user = document.getElementById('nc-user').value.trim();
|
||||
var pass = document.getElementById('nc-pass').value.trim();
|
||||
|
||||
if (!url || !user || !pass) { showToast('Fill all Nextcloud fields', 'error'); return; }
|
||||
|
||||
showLoading('Connecting to Nextcloud...');
|
||||
|
||||
fetch('/api/nextcloud/connect', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ nextcloudUrl: url, username: user, appPassword: pass })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
loadNextcloudStatus();
|
||||
document.getElementById('nc-pass').value = '';
|
||||
} else {
|
||||
showToast(data.error || 'Connection failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
document.getElementById('btn-nc-disconnect').addEventListener('click', function() {
|
||||
fetch('/api/nextcloud/disconnect', { method: 'POST', headers: getAuthHeaders() })
|
||||
.then(function() { showToast('Disconnected', 'info'); loadNextcloudStatus(); });
|
||||
});
|
||||
|
||||
document.getElementById('btn-nc-save-path').addEventListener('click', function() {
|
||||
var p = (document.getElementById('nc-webdav-path').value || '').trim();
|
||||
fetch('/api/user/webdav-path', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ path: p })
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(d) { d.success ? showToast('Path saved', 'success') : showToast(d.error || 'Failed', 'error'); })
|
||||
.catch(function() { showToast('Failed to save', 'error'); });
|
||||
});
|
||||
|
||||
console.log('✅ Nextcloud module loaded');
|
||||
});
|
||||
})();
|
||||
1714
public/js/peGuide.js
1714
public/js/peGuide.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,73 +0,0 @@
|
|||
// ============================================================
|
||||
// SECURE STORAGE WRAPPER
|
||||
// Browser: localStorage
|
||||
// Capacitor mobile: iOS Keychain / Android EncryptedSharedPreferences
|
||||
// via capacitor-secure-storage-plugin
|
||||
// ============================================================
|
||||
(function() {
|
||||
var memCache = {};
|
||||
|
||||
function isNative() {
|
||||
try {
|
||||
return !!(window.Capacitor && typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform());
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
|
||||
function plugin() {
|
||||
try {
|
||||
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SecureStoragePlugin;
|
||||
return p || null;
|
||||
} catch(e) { return null; }
|
||||
}
|
||||
|
||||
window.SecureStorage = {
|
||||
isNative: isNative,
|
||||
|
||||
get: function(key) {
|
||||
if (isNative() && plugin()) {
|
||||
return plugin().get({ key: key })
|
||||
.then(function(r) { memCache[key] = r.value; return r.value; })
|
||||
.catch(function() { return null; });
|
||||
}
|
||||
try { return Promise.resolve(localStorage.getItem(key)); } catch(e) { return Promise.resolve(null); }
|
||||
},
|
||||
|
||||
set: function(key, value) {
|
||||
memCache[key] = value;
|
||||
if (isNative() && plugin()) {
|
||||
return plugin().set({ key: key, value: value }).catch(function() {});
|
||||
}
|
||||
try { localStorage.setItem(key, value); } catch(e) {}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
remove: function(key) {
|
||||
delete memCache[key];
|
||||
if (isNative() && plugin()) {
|
||||
var p = plugin().remove({ key: key }).catch(function() {});
|
||||
// Also clear legacy localStorage copy if any
|
||||
try { localStorage.removeItem(key); } catch(e) {}
|
||||
return p;
|
||||
}
|
||||
try { localStorage.removeItem(key); } catch(e) {}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
// Synchronous read for hot paths (fetch headers). On native returns cached
|
||||
// value populated by prior get/set. On web reads localStorage directly.
|
||||
getSync: function(key) {
|
||||
if (isNative()) return memCache[key] || null;
|
||||
try { return localStorage.getItem(key); } catch(e) { return null; }
|
||||
},
|
||||
|
||||
// Hydrate the memory cache from native storage at boot. Resolves when ready.
|
||||
hydrate: function(keys) {
|
||||
if (!isNative() || !plugin()) return Promise.resolve();
|
||||
return Promise.all(keys.map(function(k) {
|
||||
return plugin().get({ key: k })
|
||||
.then(function(r) { memCache[k] = r.value; })
|
||||
.catch(function() { memCache[k] = null; });
|
||||
}));
|
||||
}
|
||||
};
|
||||
})();
|
||||
1062
public/js/shadess.js
1062
public/js/shadess.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,450 +0,0 @@
|
|||
// ============================================================
|
||||
// SICK VISIT TAB — Recording, ROS/PE inference, Note generation
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── 15 ROS systems (same keys as shadess.js) ─────────────────────────────
|
||||
var ROS_SYSTEMS = [
|
||||
{ key: 'constitutional', label: 'Constitutional', detail: 'fever, fatigue, weight loss/gain, appetite' },
|
||||
{ key: 'eyes', label: 'Eyes', detail: 'vision changes, discharge, redness' },
|
||||
{ key: 'ent', label: 'ENT', detail: 'ear pain, congestion, sore throat, mouth sores' },
|
||||
{ key: 'cardiovascular', label: 'Cardiovascular', detail: 'chest pain, palpitations, murmur' },
|
||||
{ key: 'respiratory', label: 'Respiratory', detail: 'cough, wheeze, dyspnea' },
|
||||
{ key: 'gastrointestinal', label: 'Gastrointestinal', detail: 'nausea, vomiting, diarrhea, constipation, abdominal pain' },
|
||||
{ key: 'genitourinary', label: 'Genitourinary', detail: 'dysuria, frequency, discharge, menstrual' },
|
||||
{ key: 'musculoskeletal', label: 'Musculoskeletal', detail: 'joint pain, swelling, gait issues, back pain' },
|
||||
{ key: 'skin', label: 'Skin', detail: 'rash, lesions, itch, hair/nail changes' },
|
||||
{ key: 'neurological', label: 'Neurological', detail: 'headache, dizziness, seizures, numbness' },
|
||||
{ key: 'psychiatric', label: 'Psychiatric', detail: 'mood, anxiety, sleep, behavior changes' },
|
||||
{ key: 'endocrine', label: 'Endocrine', detail: 'polyuria/polydipsia, heat/cold intolerance, growth concerns' },
|
||||
{ key: 'hematologic', label: 'Hematologic/Lymphatic', detail: 'bruising, bleeding, lymph nodes' },
|
||||
{ key: 'allergic', label: 'Allergic/Immunologic', detail: 'allergic reactions, frequent infections' },
|
||||
{ key: 'developmental', label: 'Developmental/Behavioral', detail: 'milestones, school, social' },
|
||||
];
|
||||
|
||||
// ── 17 PE systems ─────────────────────────────────────────────────────────
|
||||
var PE_SYSTEMS = [
|
||||
{ key: 'general', label: 'General Appearance', detail: 'WDWN, alert, NAD, etc.' },
|
||||
{ key: 'peEyes', label: 'Eyes', detail: 'PERRL, conjunctiva, EOM' },
|
||||
{ key: 'ears', label: 'Ears', detail: 'TMs, canals, hearing' },
|
||||
{ key: 'nose', label: 'Nose', detail: 'mucosa, septum, turbinates' },
|
||||
{ key: 'mouthThroat', label: 'Mouth/Throat', detail: 'lips, gums, teeth, tonsils, pharynx' },
|
||||
{ key: 'neck', label: 'Neck', detail: 'lymph nodes, thyroid, masses' },
|
||||
{ key: 'chestLungs', label: 'Chest/Lungs', detail: 'air entry, wheezing, retractions' },
|
||||
{ key: 'peCv', label: 'Cardiovascular', detail: 'S1/S2, murmur, pulses, cap refill' },
|
||||
{ key: 'abdomen', label: 'Abdomen', detail: 'soft, tenderness, organomegaly, BS' },
|
||||
{ key: 'guTanner', label: 'Genitourinary/Tanner', detail: 'Tanner stage, genitalia, hernias' },
|
||||
{ key: 'msk', label: 'Musculoskeletal', detail: 'strength, ROM, gait, spine' },
|
||||
{ key: 'extremities', label: 'Extremities', detail: 'tone, reflexes, clubbing, edema' },
|
||||
{ key: 'peSkin', label: 'Skin', detail: 'rashes, lesions, cafe au lait, bruises' },
|
||||
{ key: 'peNeuro', label: 'Neurological', detail: 'CN intact, DTRs, coordination' },
|
||||
{ key: 'lymphatic', label: 'Lymphatic', detail: 'cervical, axillary, inguinal nodes' },
|
||||
{ key: 'breast', label: 'Breast', detail: 'if indicated by age/Tanner' },
|
||||
{ key: 'pePsych', label: 'Psychiatric', detail: 'affect, mood, behavior during exam' },
|
||||
];
|
||||
|
||||
// ── Inference rules (CC keyword → ROS and PE system keys) ─────────────────
|
||||
var INFERENCE_RULES = [
|
||||
{ keywords: ['ear', 'hearing', 'otalgia', 'otitis'],
|
||||
ros: ['constitutional', 'ent'],
|
||||
pe: ['general', 'ears', 'nose', 'mouthThroat', 'neck'] },
|
||||
{ keywords: ['throat', 'strep', 'pharyn', 'tonsil'],
|
||||
ros: ['constitutional', 'ent'],
|
||||
pe: ['general', 'mouthThroat', 'neck', 'ears', 'lymphatic'] },
|
||||
{ keywords: ['cough', 'breath', 'asthma', 'wheez', 'respiratory', 'bronch'],
|
||||
ros: ['constitutional', 'respiratory', 'cardiovascular', 'ent'],
|
||||
pe: ['general', 'chestLungs', 'peCv', 'ears', 'nose'] },
|
||||
{ keywords: ['stomach', 'vomit', 'nausea', 'diarr', 'abdomin', 'constip', 'belly'],
|
||||
ros: ['constitutional', 'gastrointestinal', 'genitourinary'],
|
||||
pe: ['general', 'abdomen', 'chestLungs', 'peCv', 'peSkin'] },
|
||||
{ keywords: ['fever', 'temp', 'hot'],
|
||||
ros: ['constitutional', 'ent', 'respiratory', 'gastrointestinal'],
|
||||
pe: ['general', 'ears', 'mouthThroat', 'chestLungs', 'abdomen', 'peSkin', 'lymphatic'] },
|
||||
{ keywords: ['rash', 'skin', 'itch', 'eczema', 'hives', 'dermat'],
|
||||
ros: ['constitutional', 'skin', 'allergic'],
|
||||
pe: ['general', 'peSkin', 'chestLungs', 'peCv', 'lymphatic'] },
|
||||
{ keywords: ['headache', 'head', 'migrain'],
|
||||
ros: ['constitutional', 'neurological', 'eyes', 'ent'],
|
||||
pe: ['general', 'peNeuro', 'peEyes', 'ears', 'neck'] },
|
||||
{ keywords: ['urin', 'uti', 'dysuria', 'bladder', 'kidney'],
|
||||
ros: ['constitutional', 'genitourinary', 'gastrointestinal'],
|
||||
pe: ['general', 'abdomen', 'guTanner', 'chestLungs', 'peCv'] },
|
||||
{ keywords: ['joint', 'knee', 'limb', 'leg', 'arm', 'ankle', 'musculo', 'back', 'pain'],
|
||||
ros: ['constitutional', 'musculoskeletal', 'neurological'],
|
||||
pe: ['general', 'msk', 'extremities', 'peNeuro', 'peSkin'] },
|
||||
{ keywords: ['anxi', 'mood', 'behav', 'depress', 'mental', 'school'],
|
||||
ros: ['constitutional', 'psychiatric', 'neurological', 'developmental'],
|
||||
pe: ['general', 'pePsych', 'peNeuro', 'peSkin'] },
|
||||
];
|
||||
|
||||
var DEFAULT_ROS = ['constitutional', 'ent', 'respiratory', 'gastrointestinal'];
|
||||
var DEFAULT_PE = ['general', 'ears', 'chestLungs', 'peCv', 'abdomen', 'peSkin', 'peNeuro'];
|
||||
|
||||
function inferSystems(cc) {
|
||||
var lc = (cc || '').toLowerCase();
|
||||
var rosSet = {};
|
||||
var peSet = {};
|
||||
var matched = false;
|
||||
|
||||
INFERENCE_RULES.forEach(function(rule) {
|
||||
if (rule.keywords.some(function(kw) { return lc.indexOf(kw) !== -1; })) {
|
||||
matched = true;
|
||||
rule.ros.forEach(function(k) { rosSet[k] = true; });
|
||||
rule.pe.forEach(function(k) { peSet[k] = true; });
|
||||
}
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
DEFAULT_ROS.forEach(function(k) { rosSet[k] = true; });
|
||||
DEFAULT_PE.forEach(function(k) { peSet[k] = true; });
|
||||
}
|
||||
|
||||
// Return top 4 ROS, top 7 PE
|
||||
var rosKeys = Object.keys(rosSet).slice(0, 4);
|
||||
var peKeys = Object.keys(peSet).slice(0, 7);
|
||||
|
||||
// Ensure we always have at least the default counts
|
||||
DEFAULT_ROS.forEach(function(k) { if (rosKeys.length < 4 && rosKeys.indexOf(k) === -1) rosKeys.push(k); });
|
||||
DEFAULT_PE.forEach(function(k) { if (peKeys.length < 7 && peKeys.indexOf(k) === -1) peKeys.push(k); });
|
||||
|
||||
return {
|
||||
ros: rosKeys.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean),
|
||||
pe: peKeys.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Module state ─────────────────────────────────────────────────────────
|
||||
var _sickRosData = {};
|
||||
var _sickPeData = {};
|
||||
var _sickSelectedDx = [];
|
||||
var _sickRosSystems = DEFAULT_ROS.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
|
||||
var _sickPeSystems = DEFAULT_PE.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
|
||||
|
||||
// ── Recording ────────────────────────────────────────────────────────────
|
||||
var _sickRecorder = null;
|
||||
var _sickTimer = null;
|
||||
var _sickRecording = false;
|
||||
var _sickPaused = false;
|
||||
var _sickRecognition = null;
|
||||
var _sickTranscript = '';
|
||||
|
||||
function initRecording() {
|
||||
var recordBtn = document.getElementById('sick-record-btn');
|
||||
var pauseBtn = document.getElementById('sick-pause-btn');
|
||||
var indicator = document.getElementById('sick-rec-indicator');
|
||||
var timerEl = document.getElementById('sick-timer');
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
if (!recordBtn) return;
|
||||
|
||||
_sickTimer = createTimer(timerEl);
|
||||
_sickRecognition = createSpeechRecognition();
|
||||
if (_sickRecognition) {
|
||||
_sickRecognition.onresult = function(e) {
|
||||
var interim = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) _sickTranscript += e.results[i][0].transcript + ' ';
|
||||
else interim = e.results[i][0].transcript;
|
||||
}
|
||||
if (transcriptEl) transcriptEl.innerHTML = _sickTranscript + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
||||
};
|
||||
_sickRecognition.onend = function() { if (_sickRecording && !_sickPaused) try { _sickRecognition.start(); } catch(e) {} };
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', function() {
|
||||
if (!_sickRecording) {
|
||||
_sickTranscript = '';
|
||||
_sickRecorder = new AudioRecorder();
|
||||
_sickRecorder.start().then(function() {
|
||||
_sickRecording = true; _sickPaused = false;
|
||||
recordBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
|
||||
recordBtn.classList.add('recording');
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
_sickTimer.start();
|
||||
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}
|
||||
showToast('Sick visit recording started', 'info');
|
||||
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'sick' } }));
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
_sickRecording = false; _sickPaused = false;
|
||||
var dur = _sickTimer.stop();
|
||||
recordBtn.innerHTML = '<i class="fas fa-microphone"></i> Listen In';
|
||||
recordBtn.classList.remove('recording');
|
||||
if (pauseBtn) pauseBtn.classList.add('hidden');
|
||||
indicator.classList.add('hidden');
|
||||
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
|
||||
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'sick' } }));
|
||||
|
||||
showBusy('Transcribing...');
|
||||
_sickRecorder.stop().then(function(blob) {
|
||||
if (!blob || blob.size === 0) { hideBusy(); if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); return; }
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) { if (transcriptEl) transcriptEl.textContent = data.text; _sickTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
||||
else { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); }
|
||||
});
|
||||
}).catch(function() { hideBusy(); });
|
||||
}
|
||||
});
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!_sickRecording || !_sickRecorder || !_sickRecorder.mediaRecorder) return;
|
||||
if (!_sickPaused) {
|
||||
try { if (_sickRecorder.mediaRecorder.state === 'recording') _sickRecorder.mediaRecorder.pause(); } catch(e) {}
|
||||
_sickTimer.stop(); _sickPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
|
||||
} else {
|
||||
try {
|
||||
if (_sickRecorder.mediaRecorder.state === 'paused') { _sickRecorder.mediaRecorder.resume(); }
|
||||
else if (_sickRecorder.mediaRecorder.state === 'inactive' && _sickRecorder.stream && _sickRecorder.stream.active) {
|
||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
_sickRecorder.mediaRecorder = new MediaRecorder(_sickRecorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
_sickRecorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _sickRecorder.chunks.push(e.data); };
|
||||
_sickRecorder.mediaRecorder.start(1000);
|
||||
}
|
||||
} catch(e) {}
|
||||
_sickTimer.resume(); _sickPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ROS/PE rendering ──────────────────────────────────────────────────────
|
||||
function populateAddDropdowns() {
|
||||
var rosAddEl = document.getElementById('sick-ros-add');
|
||||
var peAddEl = document.getElementById('sick-pe-add');
|
||||
|
||||
if (rosAddEl) {
|
||||
rosAddEl.innerHTML = '<option value="">+ Add system</option>' +
|
||||
ROS_SYSTEMS.map(function(s) {
|
||||
return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>';
|
||||
}).join('');
|
||||
rosAddEl.addEventListener('change', function() {
|
||||
var key = this.value;
|
||||
if (!key) return;
|
||||
this.value = '';
|
||||
var sys = ROS_SYSTEMS.find(function(s) { return s.key === key; });
|
||||
if (!sys) return;
|
||||
if (_sickRosSystems.some(function(s) { return s.key === key; })) return;
|
||||
_sickRosSystems.push(sys);
|
||||
renderRosPe();
|
||||
});
|
||||
}
|
||||
|
||||
if (peAddEl) {
|
||||
peAddEl.innerHTML = '<option value="">+ Add system</option>' +
|
||||
PE_SYSTEMS.map(function(s) {
|
||||
return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>';
|
||||
}).join('');
|
||||
peAddEl.addEventListener('change', function() {
|
||||
var key = this.value;
|
||||
if (!key) return;
|
||||
this.value = '';
|
||||
var sys = PE_SYSTEMS.find(function(s) { return s.key === key; });
|
||||
if (!sys) return;
|
||||
if (_sickPeSystems.some(function(s) { return s.key === key; })) return;
|
||||
_sickPeSystems.push(sys);
|
||||
renderRosPe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderRosPe() {
|
||||
var rosContainer = document.getElementById('sick-ros-container');
|
||||
var peContainer = document.getElementById('sick-pe-container');
|
||||
|
||||
if (rosContainer && typeof window.renderRosRows === 'function') {
|
||||
rosContainer.innerHTML = window.renderRosRows(_sickRosSystems, _sickRosData, 'sros', { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' });
|
||||
window.wireRosContainer(rosContainer, _sickRosData);
|
||||
}
|
||||
if (peContainer && typeof window.renderRosRows === 'function') {
|
||||
peContainer.innerHTML = window.renderRosRows(_sickPeSystems, _sickPeData, 'spe', { wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' });
|
||||
window.wireRosContainer(peContainer, _sickPeData);
|
||||
}
|
||||
}
|
||||
|
||||
function inferAndRender() {
|
||||
var cc = (document.getElementById('sick-cc') || {}).value || '';
|
||||
var inferred = inferSystems(cc);
|
||||
_sickRosSystems = inferred.ros;
|
||||
_sickPeSystems = inferred.pe;
|
||||
_sickRosData = {};
|
||||
_sickPeData = {};
|
||||
renderRosPe();
|
||||
if (cc) showToast('ROS/PE updated for: ' + cc, 'info');
|
||||
}
|
||||
|
||||
// ── Diagnoses ─────────────────────────────────────────────────────────────
|
||||
function initDx() {
|
||||
var dxContainer = document.getElementById('sick-dx-container');
|
||||
if (dxContainer && typeof window.renderDxComponent === 'function') {
|
||||
window.renderDxComponent(dxContainer, _sickSelectedDx, 'sick');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate note ─────────────────────────────────────────────────────────
|
||||
function generateSickNote() {
|
||||
var age = (document.getElementById('sick-age') || {}).value || '';
|
||||
var gender = (document.getElementById('sick-gender') || {}).value || '';
|
||||
var cc = (document.getElementById('sick-cc') || {}).value || '';
|
||||
if (!cc) { showToast('Enter a chief complaint first', 'error'); return; }
|
||||
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
var transcript = transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '';
|
||||
|
||||
var rosText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickRosSystems, _sickRosData, 'Review of Systems') : '';
|
||||
var peText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickPeSystems, _sickPeData, 'Physical Examination') : '';
|
||||
var dxText = typeof window.formatDxForAI === 'function' ? window.formatDxForAI(_sickSelectedDx, 'sick-dx-freetext') : '';
|
||||
|
||||
showBusy('Generating sick visit note...');
|
||||
|
||||
var modelEl = document.getElementById('sick-model-select');
|
||||
|
||||
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||
|
||||
memoriesPromise.then(function(memCtx) {
|
||||
return fetch('/api/sick-visit/note', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
patientAge: age,
|
||||
patientGender: gender,
|
||||
chiefComplaint: cc,
|
||||
transcript: transcript || null,
|
||||
ros: rosText || null,
|
||||
physicalExam: peText || null,
|
||||
diagnoses: dxText || null,
|
||||
physicianMemories: memCtx || null,
|
||||
model: modelEl ? modelEl.value : getSelectedModel()
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
var noteEl = document.getElementById('sick-note-text');
|
||||
var outCard = document.getElementById('sick-note-output');
|
||||
var tag = document.getElementById('sick-note-model-tag');
|
||||
if (noteEl) setOutputText(noteEl, data.note);
|
||||
if (typeof trackAIOutput === 'function') trackAIOutput('sick-note-text', data.note);
|
||||
var sickTranscript = document.getElementById('sick-transcript');
|
||||
if (sickTranscript) storeSourceContext('sick-note-text', sickTranscript.innerText.trim());
|
||||
if (tag) tag.textContent = (data.model || '').split('/').pop();
|
||||
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
|
||||
showToast('Sick visit note generated!', 'success');
|
||||
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('sick-note-text', data.note, 'sickvisit', document.getElementById('sick-age').value);
|
||||
} else {
|
||||
showToast(data.error || 'Generation failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
// ── Reset ─────────────────────────────────────────────────────────────────
|
||||
function resetSickVisit() {
|
||||
var fields = ['sick-age', 'sick-gender', 'sick-cc', 'sick-label'];
|
||||
fields.forEach(function(id) { var el = document.getElementById(id); if (el) el.value = ''; });
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
if (transcriptEl) transcriptEl.textContent = '';
|
||||
_sickTranscript = '';
|
||||
_sickRosData = {};
|
||||
_sickPeData = {};
|
||||
_sickSelectedDx.length = 0;
|
||||
_sickRosSystems = DEFAULT_ROS.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
|
||||
_sickPeSystems = DEFAULT_PE.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
|
||||
renderRosPe();
|
||||
initDx();
|
||||
var outCard = document.getElementById('sick-note-output');
|
||||
var noteEl = document.getElementById('sick-note-text');
|
||||
if (outCard) outCard.classList.add('hidden');
|
||||
if (noteEl) noteEl.textContent = '';
|
||||
window._savedEncId_sickvisit = null;
|
||||
showToast('Sick visit cleared for new patient', 'info');
|
||||
}
|
||||
|
||||
// ── Click handler ─────────────────────────────────────────────────────────
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-sick-generate')) generateSickNote();
|
||||
if (e.target.closest('#btn-sick-infer')) inferAndRender();
|
||||
if (e.target.closest('#btn-sick-new-patient')) resetSickVisit();
|
||||
if (e.target.closest('#sick-refine-btn')) refineDocument('sick-note-text', 'sick-refine-input');
|
||||
if (e.target.closest('#sick-shorten-btn')) shortenDocument('sick-note-text');
|
||||
|
||||
if (e.target.closest('#sick-ros-all-wnl')) {
|
||||
_sickRosSystems.forEach(function(sys) { _sickRosData[sys.key + '_status'] = 'wnl'; });
|
||||
renderRosPe();
|
||||
showToast('All ROS set to WNL', 'success');
|
||||
}
|
||||
if (e.target.closest('#sick-ros-clear')) {
|
||||
_sickRosSystems.forEach(function(sys) { _sickRosData[sys.key + '_status'] = ''; _sickRosData[sys.key + '_note'] = ''; });
|
||||
renderRosPe();
|
||||
showToast('ROS cleared', 'info');
|
||||
}
|
||||
if (e.target.closest('#sick-pe-all-normal')) {
|
||||
_sickPeSystems.forEach(function(sys) { _sickPeData[sys.key + '_status'] = 'wnl'; });
|
||||
renderRosPe();
|
||||
showToast('All PE set to Normal', 'success');
|
||||
}
|
||||
if (e.target.closest('#sick-pe-clear')) {
|
||||
_sickPeSystems.forEach(function(sys) { _sickPeData[sys.key + '_status'] = ''; _sickPeData[sys.key + '_note'] = ''; });
|
||||
renderRosPe();
|
||||
showToast('PE cleared', 'info');
|
||||
}
|
||||
|
||||
if (e.target.closest('#btn-sick-save')) {
|
||||
var labelEl = document.getElementById('sick-label');
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
var noteEl = document.getElementById('sick-note-text');
|
||||
if (typeof saveEncounter === 'function') {
|
||||
saveEncounter({
|
||||
id: window._savedEncId_sickvisit || null,
|
||||
label: labelEl ? labelEl.value.trim() : '',
|
||||
enc_type: 'sickvisit',
|
||||
transcript: transcriptEl ? (transcriptEl.innerText || '') : '',
|
||||
generated_note: noteEl ? (noteEl.innerText || '') : '',
|
||||
onSaved: function(id) { window._savedEncId_sickvisit = id; }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Load handler ──────────────────────────────────────────────────────────
|
||||
if (typeof registerEncounterLoadHandler === 'function') {
|
||||
registerEncounterLoadHandler('sickvisit', function(enc) {
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
if (transcriptEl && enc.transcript) transcriptEl.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
var noteEl = document.getElementById('sick-note-text');
|
||||
var outCard = document.getElementById('sick-note-output');
|
||||
if (noteEl) noteEl.textContent = enc.generated_note;
|
||||
if (outCard) outCard.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
var _inited = false;
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'sickvisit' || _inited) return;
|
||||
_inited = true;
|
||||
initRecording();
|
||||
populateAddDropdowns();
|
||||
renderRosPe();
|
||||
initDx();
|
||||
});
|
||||
|
||||
console.log('SickVisit module loaded');
|
||||
|
||||
})();
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
(function() {
|
||||
var _inited = false;
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'soap' || _inited) return;
|
||||
_inited = true;
|
||||
var recordBtn = document.getElementById('soap-record-btn');
|
||||
var indicator = document.getElementById('soap-recording-indicator');
|
||||
var timerEl = document.getElementById('soap-timer');
|
||||
var transcript = document.getElementById('soap-transcript');
|
||||
var clearBtn = document.getElementById('soap-clear');
|
||||
var generateBtn = document.getElementById('soap-generate-btn');
|
||||
var outputCard = document.getElementById('soap-output');
|
||||
var soapText = document.getElementById('soap-text');
|
||||
var modelTag = document.getElementById('soap-model-tag');
|
||||
|
||||
var pauseBtn = document.getElementById('soap-pause-btn');
|
||||
var stopBtn = document.getElementById('soap-stop-btn');
|
||||
|
||||
var recorder = new AudioRecorder();
|
||||
recorder._module = 'soap';
|
||||
var timer = createTimer(timerEl);
|
||||
var isRecording = false;
|
||||
var isPaused = false;
|
||||
var recognition = createSpeechRecognition();
|
||||
var finalText = '';
|
||||
var sessionFinals = '';
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
if (recognition) {
|
||||
recognition.onresult = function(e) {
|
||||
var interim = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) {
|
||||
var chunk = e.results[i][0].transcript + ' ';
|
||||
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
|
||||
sessionFinals += deduped;
|
||||
} else {
|
||||
interim = e.results[i][0].transcript;
|
||||
}
|
||||
}
|
||||
var combined = finalText + sessionFinals;
|
||||
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
|
||||
};
|
||||
recognition.onend = function() {
|
||||
finalText += sessionFinals;
|
||||
sessionFinals = '';
|
||||
if (isRecording && !isPaused) try { recognition.start(); } catch(e) {}
|
||||
};
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', function() {
|
||||
if (!isRecording) {
|
||||
finalText = '';
|
||||
sessionFinals = '';
|
||||
recorder.start().then(function() {
|
||||
isRecording = true;
|
||||
isPaused = false;
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.style.display = 'none';
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
if (stopBtn) stopBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
timer.start();
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
if (typeof nativeHaptic === 'function') nativeHaptic('heavy');
|
||||
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(true);
|
||||
if (typeof nativeStartRecordingService === 'function') nativeStartRecordingService();
|
||||
showToast('Recording started', 'info');
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
isRecording = false;
|
||||
isPaused = false;
|
||||
var dur = timer.stop();
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.style.display = '';
|
||||
recordBtn.querySelector('span').textContent = 'Dictate';
|
||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; pauseBtn.classList.remove('btn-primary'); pauseBtn.classList.add('btn-ghost'); }
|
||||
if (stopBtn) stopBtn.classList.add('hidden');
|
||||
indicator.classList.add('hidden');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
if (typeof nativeHaptic === 'function') nativeHaptic('medium');
|
||||
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(false);
|
||||
if (typeof nativeStopRecordingService === 'function') nativeStopRecordingService();
|
||||
|
||||
var liveText = (finalText + sessionFinals).trim();
|
||||
if (window._transcribeAvailable === false) {
|
||||
recorder.stop().then(function() {});
|
||||
if (liveText) transcript.textContent = liveText;
|
||||
showToast('Using browser speech recognition', 'info');
|
||||
} else {
|
||||
showBusy('Transcribing...');
|
||||
recorder.stop().then(function(blob) {
|
||||
if (!blob) { hideBusy(); if (liveText) transcript.textContent = liveText; return; }
|
||||
if (blob.size > 24 * 1024 * 1024) {
|
||||
hideBusy();
|
||||
transcript.textContent = liveText;
|
||||
showToast('Recording too large for AI transcription — using live transcript', 'info');
|
||||
return;
|
||||
}
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) transcript.textContent = data.text;
|
||||
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
|
||||
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
|
||||
});
|
||||
}).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Dedicated stop button
|
||||
if (stopBtn) {
|
||||
stopBtn.addEventListener('click', function() {
|
||||
if (!isRecording) return;
|
||||
recordBtn.style.display = '';
|
||||
recordBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Pause / Resume
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!isRecording) return;
|
||||
if (!isPaused) {
|
||||
try { if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'recording') recorder.mediaRecorder.pause(); } catch(e) {}
|
||||
timer.stop();
|
||||
isPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
pauseBtn.classList.add('btn-primary');
|
||||
pauseBtn.classList.remove('btn-ghost');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
showToast('Paused', 'info');
|
||||
} else {
|
||||
try {
|
||||
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'paused') { recorder.mediaRecorder.resume(); }
|
||||
else if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'inactive' && recorder.stream && recorder.stream.active) {
|
||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
recorder.mediaRecorder = new MediaRecorder(recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recorder.chunks.push(e.data); };
|
||||
recorder.mediaRecorder.start(1000);
|
||||
}
|
||||
} catch(e) {}
|
||||
timer.resume();
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
showToast('Resumed', 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearBtn.addEventListener('click', function() {
|
||||
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
|
||||
var instrEl = document.getElementById('soap-instructions');
|
||||
if (instrEl) instrEl.value = '';
|
||||
window._savedEncId_soap = null;
|
||||
var labelEl = document.getElementById('soap-label');
|
||||
if (labelEl) labelEl.value = '';
|
||||
});
|
||||
|
||||
generateBtn.addEventListener('click', function() {
|
||||
var text = transcript.innerText.trim();
|
||||
if (!text) { showToast('No input', 'error'); return; }
|
||||
|
||||
showBusy('Generating SOAP note...');
|
||||
|
||||
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||
|
||||
memoriesPromise.then(function(memCtx) {
|
||||
return fetch('/api/generate-soap', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
transcript: text,
|
||||
patientAge: document.getElementById('soap-age').value,
|
||||
patientGender: document.getElementById('soap-gender').value,
|
||||
type: document.getElementById('soap-type').value,
|
||||
additionalInstructions: document.getElementById('soap-instructions').value,
|
||||
physicianMemories: memCtx || null,
|
||||
model: getSelectedModel()
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
setOutputText(soapText, data.soap);
|
||||
storeSourceContext('soap-text', transcript.innerText.trim());
|
||||
if (typeof trackAIOutput === 'function') trackAIOutput('soap-text', data.soap);
|
||||
modelTag.textContent = (data.model || '').split('/').pop();
|
||||
outputCard.classList.remove('hidden');
|
||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||
showToast('SOAP note generated!', 'success');
|
||||
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('soap-text', data.soap, 'soap', document.getElementById('soap-age').value);
|
||||
} else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
document.getElementById('soap-refine-btn').addEventListener('click', function() { refineDocument('soap-text', 'soap-refine-input'); });
|
||||
document.getElementById('soap-shorten-btn').addEventListener('click', function() { shortenDocument('soap-text'); });
|
||||
|
||||
// Register load handler for resuming saved SOAP notes
|
||||
if (typeof registerEncounterLoadHandler === 'function') {
|
||||
registerEncounterLoadHandler('soap', function(enc) {
|
||||
if (enc.transcript) transcript.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
setOutputText(soapText, enc.generated_note);
|
||||
outputCard.classList.remove('hidden');
|
||||
}
|
||||
try {
|
||||
var pd = JSON.parse(enc.partial_data || '{}');
|
||||
if (pd.age) document.getElementById('soap-age').value = pd.age;
|
||||
if (pd.gender) document.getElementById('soap-gender').value = pd.gender;
|
||||
if (pd.type) document.getElementById('soap-type').value = pd.type;
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ SOAP module loaded');
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
// ============================================================
|
||||
// WEB SPEECH RECOGNITION — Real-time streaming transcription
|
||||
// ⚠️ PRIVACY WARNING: Uses browser's built-in ASR which may
|
||||
// send audio to cloud servers (Chrome/Edge → Google servers)
|
||||
// Only use if you accept this trade-off for real-time streaming
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
var STORAGE_ENABLED = 'ped_web_speech_enabled';
|
||||
|
||||
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
var _recognition = null;
|
||||
var _transcript = '';
|
||||
var _isListening = false;
|
||||
var _onPartialCallback = null;
|
||||
var _onFinalCallback = null;
|
||||
|
||||
window.WebSpeechRecognition = {
|
||||
|
||||
isSupported: function() {
|
||||
return typeof SpeechRecognition !== 'undefined';
|
||||
},
|
||||
|
||||
isEnabled: function() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_ENABLED) === '1';
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled: function(val) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_ENABLED, val ? '1' : '0');
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
startListening: function(options) {
|
||||
if (!this.isSupported()) {
|
||||
return Promise.reject(new Error('Web Speech API not supported'));
|
||||
}
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
return Promise.reject(new Error('Web Speech Recognition not enabled'));
|
||||
}
|
||||
|
||||
_transcript = '';
|
||||
_isListening = true;
|
||||
|
||||
var opts = options || {};
|
||||
_onPartialCallback = opts.onPartial || function() {};
|
||||
_onFinalCallback = opts.onFinal || function() {};
|
||||
|
||||
_recognition = new SpeechRecognition();
|
||||
_recognition.continuous = true; // Keep listening
|
||||
_recognition.interimResults = true; // Show partial results
|
||||
_recognition.lang = opts.language || 'en-US';
|
||||
_recognition.maxAlternatives = 1;
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
_recognition.onstart = function() {
|
||||
console.log('[WebSpeech] Started listening');
|
||||
};
|
||||
|
||||
_recognition.onresult = function(event) {
|
||||
var interim = '';
|
||||
var final = '';
|
||||
|
||||
for (var i = event.resultIndex; i < event.results.length; i++) {
|
||||
var transcript = event.results[i][0].transcript;
|
||||
|
||||
if (event.results[i].isFinal) {
|
||||
final += transcript + ' ';
|
||||
_transcript += transcript + ' ';
|
||||
} else {
|
||||
interim += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
// Callback with partial results (shown in real-time)
|
||||
if (interim && _onPartialCallback) {
|
||||
_onPartialCallback(interim);
|
||||
}
|
||||
|
||||
// Callback with final results (confirmed words)
|
||||
if (final && _onFinalCallback) {
|
||||
_onFinalCallback(final.trim());
|
||||
}
|
||||
};
|
||||
|
||||
_recognition.onerror = function(event) {
|
||||
console.error('[WebSpeech] Error:', event.error);
|
||||
_isListening = false;
|
||||
|
||||
if (event.error === 'no-speech') {
|
||||
reject(new Error('No speech detected'));
|
||||
} else if (event.error === 'not-allowed') {
|
||||
reject(new Error('Microphone permission denied'));
|
||||
} else {
|
||||
reject(new Error('Speech recognition error: ' + event.error));
|
||||
}
|
||||
};
|
||||
|
||||
_recognition.onend = function() {
|
||||
_isListening = false;
|
||||
console.log('[WebSpeech] Stopped listening');
|
||||
resolve(_transcript.trim());
|
||||
};
|
||||
|
||||
try {
|
||||
_recognition.start();
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to start recognition: ' + e.message));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
stopListening: function() {
|
||||
if (_recognition && _isListening) {
|
||||
_recognition.stop();
|
||||
}
|
||||
},
|
||||
|
||||
isListening: function() {
|
||||
return _isListening;
|
||||
},
|
||||
|
||||
getCurrentTranscript: function() {
|
||||
return _transcript.trim();
|
||||
},
|
||||
|
||||
// Check if browser likely sends to cloud
|
||||
getPrivacyInfo: function() {
|
||||
var isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
|
||||
var isEdge = /Edg/.test(navigator.userAgent);
|
||||
|
||||
if (isChrome || isEdge) {
|
||||
return {
|
||||
provider: 'Google Cloud Speech',
|
||||
privacy: 'Audio sent to Google servers',
|
||||
warning: 'NOT HIPAA-compliant'
|
||||
};
|
||||
} else if (/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)) {
|
||||
return {
|
||||
provider: 'Apple Speech Recognition',
|
||||
privacy: 'May process on-device or Apple servers',
|
||||
warning: 'Check Apple privacy policy'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
provider: 'Unknown',
|
||||
privacy: 'May send audio to cloud servers',
|
||||
warning: 'Privacy unknown'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
// ============================================================
|
||||
// TRANSCRIPTION SETTINGS — Browser Whisper + Web Speech API
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
var _inited = false;
|
||||
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'settings' || _inited) return;
|
||||
_inited = true;
|
||||
initTranscriptionSettings();
|
||||
});
|
||||
|
||||
// Also init on direct page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(function() {
|
||||
if (!_inited && document.getElementById('browser-whisper-enabled')) {
|
||||
_inited = true;
|
||||
initTranscriptionSettings();
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
} else {
|
||||
setTimeout(function() {
|
||||
if (!_inited && document.getElementById('browser-whisper-enabled')) {
|
||||
_inited = true;
|
||||
initTranscriptionSettings();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function initTranscriptionSettings() {
|
||||
console.log('[TranscriptionSettings] Initializing...');
|
||||
|
||||
// ── Browser Whisper ──────────────────────────────────────
|
||||
var whisperCheckbox = document.getElementById('browser-whisper-enabled');
|
||||
var whisperStatus = document.getElementById('browser-whisper-status');
|
||||
var whisperModel = document.getElementById('browser-whisper-model');
|
||||
var whisperPreload = document.getElementById('btn-whisper-preload');
|
||||
var whisperProgress = document.getElementById('browser-whisper-progress');
|
||||
var whisperProgressText = document.getElementById('browser-whisper-progress-text');
|
||||
|
||||
if (whisperCheckbox && window.BrowserWhisper) {
|
||||
whisperCheckbox.checked = BrowserWhisper.isEnabled();
|
||||
whisperStatus.textContent = BrowserWhisper.isEnabled() ? 'On — audio stays on device' : 'Off';
|
||||
|
||||
if (whisperModel) {
|
||||
whisperModel.value = BrowserWhisper.getModel();
|
||||
}
|
||||
|
||||
whisperCheckbox.addEventListener('change', function() {
|
||||
BrowserWhisper.setEnabled(whisperCheckbox.checked);
|
||||
whisperStatus.textContent = whisperCheckbox.checked ? 'On — audio stays on device' : 'Off';
|
||||
|
||||
if (whisperCheckbox.checked) {
|
||||
// Auto-preload on enable
|
||||
BrowserWhisper.preload(function(file, pct) {
|
||||
if (whisperProgress && whisperProgressText) {
|
||||
if (pct >= 100) {
|
||||
whisperProgress.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
whisperProgress.style.display = 'block';
|
||||
whisperProgressText.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (whisperModel) {
|
||||
whisperModel.addEventListener('change', function() {
|
||||
BrowserWhisper.setModel(whisperModel.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (whisperPreload) {
|
||||
whisperPreload.addEventListener('click', function() {
|
||||
if (!BrowserWhisper || !BrowserWhisper.isSupported()) {
|
||||
showToast('Browser Whisper not supported', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
whisperPreload.disabled = true;
|
||||
whisperPreload.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Downloading...';
|
||||
whisperProgress.style.display = 'block';
|
||||
whisperProgressText.textContent = 'Initializing...';
|
||||
|
||||
BrowserWhisper.setEnabled(true);
|
||||
whisperCheckbox.checked = true;
|
||||
whisperStatus.textContent = 'On — audio stays on device';
|
||||
|
||||
BrowserWhisper.preload(function(file, pct) {
|
||||
if (pct >= 100) {
|
||||
whisperProgress.style.display = 'none';
|
||||
whisperPreload.disabled = false;
|
||||
whisperPreload.innerHTML = '<i class="fas fa-download"></i> Pre-download model';
|
||||
showToast('Whisper model ready!', 'success');
|
||||
return;
|
||||
}
|
||||
whisperProgress.style.display = 'block';
|
||||
whisperProgressText.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Web Speech Recognition ───────────────────────────────
|
||||
var speechCheckbox = document.getElementById('web-speech-enabled');
|
||||
var speechStatus = document.getElementById('web-speech-status');
|
||||
var speechBrowserInfo = document.getElementById('web-speech-browser-info');
|
||||
|
||||
if (speechCheckbox && window.WebSpeechRecognition) {
|
||||
speechCheckbox.checked = WebSpeechRecognition.isEnabled();
|
||||
speechStatus.textContent = WebSpeechRecognition.isEnabled() ? 'On — real-time streaming' : 'Off';
|
||||
|
||||
// Show privacy info
|
||||
if (speechBrowserInfo) {
|
||||
var privacyInfo = WebSpeechRecognition.getPrivacyInfo();
|
||||
speechBrowserInfo.innerHTML =
|
||||
'<strong>Provider:</strong> ' + privacyInfo.provider + '<br>' +
|
||||
'<strong>Privacy:</strong> ' + privacyInfo.privacy + '<br>' +
|
||||
'<strong style="color:var(--orange);">⚠️ ' + privacyInfo.warning + '</strong>';
|
||||
}
|
||||
|
||||
speechCheckbox.addEventListener('change', function() {
|
||||
if (speechCheckbox.checked) {
|
||||
// Show confirmation for privacy warning
|
||||
showConfirm(
|
||||
'WARNING: Real-time streaming uses your browser\'s speech recognition, ' +
|
||||
'which may send audio to cloud servers (e.g., Google). ' +
|
||||
'This is NOT HIPAA-compliant. ' +
|
||||
'Only enable if you understand and accept this privacy trade-off.',
|
||||
function() {
|
||||
// Disable Browser Whisper if enabling Web Speech
|
||||
if (whisperCheckbox && whisperCheckbox.checked) {
|
||||
whisperCheckbox.checked = false;
|
||||
BrowserWhisper.setEnabled(false);
|
||||
whisperStatus.textContent = 'Off';
|
||||
showToast('Browser Whisper disabled (Web Speech takes priority)', 'info');
|
||||
}
|
||||
WebSpeechRecognition.setEnabled(true);
|
||||
speechStatus.textContent = 'On — real-time streaming';
|
||||
},
|
||||
{ danger: true, confirmText: 'Enable' }
|
||||
);
|
||||
// Revert checkbox until confirmed
|
||||
speechCheckbox.checked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
WebSpeechRecognition.setEnabled(false);
|
||||
speechStatus.textContent = 'Off';
|
||||
});
|
||||
|
||||
// If not supported, disable and show message
|
||||
if (!WebSpeechRecognition.isSupported()) {
|
||||
speechCheckbox.disabled = true;
|
||||
speechStatus.textContent = 'Not supported in this browser';
|
||||
if (speechBrowserInfo) {
|
||||
speechBrowserInfo.innerHTML = '<strong style="color:var(--red);">Not supported</strong> - Web Speech API not available in this browser.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Transcription settings initialized');
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
// ============================================================
|
||||
// UI STATE — sub-pill / sub-tab persistence across browser restart
|
||||
// ============================================================
|
||||
// Tab-level selection (ped_last_tab) is handled in app.js. This file
|
||||
// persists the finer-grained choices that would otherwise reset to
|
||||
// defaults when the user closes the browser, signs out / back in, or
|
||||
// reloads the page — because _componentCache is in-memory and cleared
|
||||
// on reload.
|
||||
//
|
||||
// All keys live under the "ped_ui/" namespace in localStorage so they
|
||||
// are easy to enumerate and clear. Reads and writes are wrapped in
|
||||
// try/catch — localStorage can throw in some contexts (Safari private
|
||||
// mode, storage quotas), and a persistence failure must never break
|
||||
// the UI.
|
||||
// ============================================================
|
||||
|
||||
(function () {
|
||||
var PREFIX = 'ped_ui/';
|
||||
|
||||
function get(key) {
|
||||
try { return localStorage.getItem(PREFIX + key); } catch (e) { return null; }
|
||||
}
|
||||
function set(key, value) {
|
||||
try { localStorage.setItem(PREFIX + key, value); } catch (e) {}
|
||||
}
|
||||
function del(key) {
|
||||
try { localStorage.removeItem(PREFIX + key); } catch (e) {}
|
||||
}
|
||||
|
||||
window.UIState = {
|
||||
get: get,
|
||||
set: set,
|
||||
del: del,
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
(function() {
|
||||
var _inited = false;
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'dictation' || _inited) return;
|
||||
_inited = true;
|
||||
var recordBtn = document.getElementById('dict-record-btn');
|
||||
var pauseBtn = document.getElementById('dict-pause-btn');
|
||||
var indicator = document.getElementById('dict-recording-indicator');
|
||||
var timerEl = document.getElementById('dict-timer');
|
||||
var transcript = document.getElementById('dict-transcript');
|
||||
var clearBtn = document.getElementById('dict-clear');
|
||||
var generateBtn = document.getElementById('dict-generate-btn');
|
||||
var outputCard = document.getElementById('dict-output');
|
||||
var hpiText = document.getElementById('dict-hpi-text');
|
||||
var modelTag = document.getElementById('dict-model-tag');
|
||||
|
||||
var recorder = new AudioRecorder();
|
||||
recorder._module = 'dictation';
|
||||
var timer = createTimer(timerEl);
|
||||
var isRecording = false;
|
||||
var isPaused = false;
|
||||
var recognition = createSpeechRecognition();
|
||||
var finalText = '';
|
||||
var sessionFinals = '';
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
if (recognition) {
|
||||
recognition.onresult = function(e) {
|
||||
var interim = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) {
|
||||
var chunk = e.results[i][0].transcript + ' ';
|
||||
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
|
||||
sessionFinals += deduped;
|
||||
} else {
|
||||
interim = e.results[i][0].transcript;
|
||||
}
|
||||
}
|
||||
var combined = finalText + sessionFinals;
|
||||
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
|
||||
};
|
||||
recognition.onend = function() {
|
||||
finalText += sessionFinals;
|
||||
sessionFinals = '';
|
||||
if (isRecording && !isPaused) try { recognition.start(); } catch(e) {}
|
||||
};
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', function() {
|
||||
if (!isRecording) {
|
||||
finalText = '';
|
||||
sessionFinals = '';
|
||||
recorder.start().then(function() {
|
||||
isRecording = true;
|
||||
isPaused = false;
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.querySelector('span').textContent = 'Stop';
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
timer.start();
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'dict' } }));
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
isRecording = false;
|
||||
isPaused = false;
|
||||
var dur = timer.stop();
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.querySelector('span').textContent = 'Start Dictation';
|
||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; }
|
||||
indicator.classList.add('hidden');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'dict' } }));
|
||||
|
||||
var liveText = (finalText + sessionFinals).trim();
|
||||
if (window._transcribeAvailable === false) {
|
||||
recorder.stop().then(function() {});
|
||||
if (liveText) transcript.textContent = liveText;
|
||||
showToast('Using browser speech recognition (no transcription API configured)', 'info');
|
||||
} else {
|
||||
showBusy('Transcribing...');
|
||||
recorder.stop().then(function(blob) {
|
||||
if (!blob || blob.size === 0) { hideBusy(); if (liveText) transcript.textContent = liveText; return; }
|
||||
if (blob.size > 24 * 1024 * 1024) {
|
||||
hideBusy();
|
||||
transcript.textContent = liveText;
|
||||
showToast('Recording too large for AI transcription — using live transcript', 'info');
|
||||
return;
|
||||
}
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
||||
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
|
||||
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
|
||||
});
|
||||
}).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pause / Resume
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!isRecording) return;
|
||||
if (!isPaused) {
|
||||
try { if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'recording') recorder.mediaRecorder.pause(); } catch(e) {}
|
||||
timer.stop();
|
||||
isPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
pauseBtn.classList.add('btn-primary');
|
||||
pauseBtn.classList.remove('btn-ghost');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
showToast('Paused', 'info');
|
||||
} else {
|
||||
try {
|
||||
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'paused') { recorder.mediaRecorder.resume(); }
|
||||
else if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'inactive' && recorder.stream && recorder.stream.active) {
|
||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
recorder.mediaRecorder = new MediaRecorder(recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recorder.chunks.push(e.data); };
|
||||
recorder.mediaRecorder.start(1000);
|
||||
}
|
||||
} catch(e) {}
|
||||
timer.resume();
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
showToast('Resumed', 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearBtn.addEventListener('click', function() {
|
||||
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
|
||||
window._savedEncId_dictation = null;
|
||||
var labelEl = document.getElementById('dict-label');
|
||||
if (labelEl) labelEl.value = '';
|
||||
var refineEl = document.getElementById('dict-refine-input');
|
||||
if (refineEl) refineEl.value = '';
|
||||
});
|
||||
|
||||
generateBtn.addEventListener('click', function() {
|
||||
var text = transcript.innerText.trim();
|
||||
if (!text) { showToast('No dictation', 'error'); return; }
|
||||
|
||||
var outputType = document.getElementById('dict-output-type').value;
|
||||
var endpoint, bodyData;
|
||||
|
||||
showBusy('Generating...');
|
||||
|
||||
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||
|
||||
memoriesPromise.then(function(memCtx) {
|
||||
if (outputType === 'soap-full' || outputType === 'soap-subjective') {
|
||||
endpoint = '/api/generate-soap';
|
||||
bodyData = {
|
||||
transcript: text,
|
||||
patientAge: document.getElementById('dict-age').value,
|
||||
patientGender: document.getElementById('dict-gender').value,
|
||||
type: outputType === 'soap-full' ? 'full' : 'subjective',
|
||||
physicianMemories: memCtx || null,
|
||||
model: getSelectedModel()
|
||||
};
|
||||
} else {
|
||||
endpoint = '/api/generate-hpi-dictation';
|
||||
bodyData = {
|
||||
transcript: text,
|
||||
patientAge: document.getElementById('dict-age').value,
|
||||
patientGender: document.getElementById('dict-gender').value,
|
||||
setting: document.getElementById('dict-setting').value,
|
||||
physicianMemories: memCtx || null,
|
||||
model: getSelectedModel()
|
||||
};
|
||||
}
|
||||
return fetch(endpoint, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(bodyData) });
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (data.success) {
|
||||
setOutputText(hpiText, data.hpi || data.soap);
|
||||
storeSourceContext('dict-hpi-text', transcript.innerText.trim());
|
||||
if (typeof trackAIOutput === 'function') trackAIOutput('dict-hpi-text', data.hpi || data.soap);
|
||||
modelTag.textContent = (data.model || '').split('/').pop();
|
||||
outputCard.classList.remove('hidden');
|
||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||
showToast('Generated!', 'success');
|
||||
} else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
});
|
||||
|
||||
document.getElementById('dict-refine-btn').addEventListener('click', function() { refineDocument('dict-hpi-text', 'dict-refine-input'); });
|
||||
document.getElementById('dict-shorten-btn').addEventListener('click', function() { shortenDocument('dict-hpi-text'); });
|
||||
|
||||
// Register load handler
|
||||
if (typeof registerEncounterLoadHandler === 'function') {
|
||||
registerEncounterLoadHandler('dictation', function(enc) {
|
||||
if (enc.transcript) transcript.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
hpiText.textContent = enc.generated_note;
|
||||
outputCard.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Dictation module loaded');
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
// ============================================================
|
||||
// VOICE PREFERENCES — STT Model & TTS Voice selection
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
var _inited = false;
|
||||
|
||||
// Listen for tab changes (correct event name is 'tabChanged' not 'tab-loaded')
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'settings' || _inited) return;
|
||||
_inited = true;
|
||||
console.log('[VoicePrefs] Initializing on settings tab load...');
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(function() {
|
||||
initVoicePreferences();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Also init on DOMContentLoaded as backup for direct page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(function() {
|
||||
if (!_inited && document.getElementById('btn-preview-voice')) {
|
||||
console.log('[VoicePrefs] Init via DOMContentLoaded (direct settings page load)');
|
||||
_inited = true;
|
||||
initVoicePreferences();
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
} else {
|
||||
// Page already loaded, init immediately if on settings
|
||||
setTimeout(function() {
|
||||
if (!_inited && document.getElementById('btn-preview-voice')) {
|
||||
console.log('[VoicePrefs] Init immediate (page already loaded)');
|
||||
_inited = true;
|
||||
initVoicePreferences();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function initVoicePreferences() {
|
||||
console.log('[VoicePrefs] initVoicePreferences called');
|
||||
|
||||
loadVoiceOptions();
|
||||
loadUserPreferences();
|
||||
|
||||
// Save button
|
||||
var btnSave = document.getElementById('btn-save-voice-prefs');
|
||||
if (btnSave) {
|
||||
console.log('[VoicePrefs] Save button found, adding listener');
|
||||
btnSave.addEventListener('click', saveVoicePreferences);
|
||||
} else {
|
||||
console.warn('[VoicePrefs] Save button NOT found');
|
||||
}
|
||||
|
||||
// Preview button
|
||||
var btnPreview = document.getElementById('btn-preview-voice');
|
||||
if (btnPreview) {
|
||||
console.log('[VoicePrefs] Preview button found, adding listener');
|
||||
btnPreview.addEventListener('click', function(e) {
|
||||
console.log('[VoicePrefs] Preview button clicked!');
|
||||
e.preventDefault();
|
||||
previewVoice();
|
||||
});
|
||||
} else {
|
||||
console.warn('[VoicePrefs] Preview button NOT found');
|
||||
}
|
||||
}
|
||||
|
||||
function loadVoiceOptions() {
|
||||
fetch('/api/user/preferences/options', {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
|
||||
// Populate STT models
|
||||
var sttSelect = document.getElementById('stt-model-select');
|
||||
if (sttSelect && data.sttModels && data.sttModels.length > 0) {
|
||||
sttSelect.innerHTML = '<option value="">Server default (' + data.sttProvider + ')</option>';
|
||||
data.sttModels.forEach(function(model) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = model.value;
|
||||
opt.textContent = model.label;
|
||||
sttSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Populate TTS voices
|
||||
var ttsSelect = document.getElementById('tts-voice-select');
|
||||
if (ttsSelect && data.ttsVoices && data.ttsVoices.length > 0) {
|
||||
ttsSelect.innerHTML = '<option value="">Server default (' + data.ttsProvider + ')</option>';
|
||||
data.ttsVoices.forEach(function(voice) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = voice.value;
|
||||
opt.textContent = voice.label;
|
||||
ttsSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[VoicePrefs] Failed to load options:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function loadUserPreferences() {
|
||||
fetch('/api/user/preferences', {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
|
||||
var sttSelect = document.getElementById('stt-model-select');
|
||||
if (sttSelect && data.stt_model) {
|
||||
sttSelect.value = data.stt_model;
|
||||
}
|
||||
|
||||
var ttsSelect = document.getElementById('tts-voice-select');
|
||||
if (ttsSelect && data.tts_voice) {
|
||||
ttsSelect.value = data.tts_voice;
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[VoicePrefs] Failed to load preferences:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function saveVoicePreferences() {
|
||||
var sttSelect = document.getElementById('stt-model-select');
|
||||
var ttsSelect = document.getElementById('tts-voice-select');
|
||||
|
||||
var sttModel = sttSelect ? sttSelect.value : null;
|
||||
var ttsVoice = ttsSelect ? ttsSelect.value : null;
|
||||
|
||||
fetch('/api/user/preferences', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
stt_model: sttModel || null,
|
||||
tts_voice: ttsVoice || null
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast('Voice preferences saved!', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to save', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
showToast('Save failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function previewVoice() {
|
||||
var ttsSelect = document.getElementById('tts-voice-select');
|
||||
var voice = ttsSelect ? ttsSelect.value : null;
|
||||
|
||||
// Allow "Server default" (empty value) to preview
|
||||
var displayVoice = voice || 'server default';
|
||||
var text = 'Hello, this is a preview of the ' + displayVoice + ' voice. This is how your read-aloud feature will sound.';
|
||||
|
||||
var btnPreview = document.getElementById('btn-preview-voice');
|
||||
if (btnPreview) {
|
||||
btnPreview.disabled = true;
|
||||
btnPreview.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||
}
|
||||
|
||||
// Save current preference temporarily (or clear it if "server default" selected)
|
||||
fetch('/api/user/preferences', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ tts_voice: voice || null })
|
||||
})
|
||||
.then(function() {
|
||||
// Generate audio with new voice
|
||||
return fetch('/api/text-to-speech', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ text: text })
|
||||
});
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('Preview failed');
|
||||
return r.blob();
|
||||
})
|
||||
.then(function(blob) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var audio = new Audio(url);
|
||||
audio.onended = function() { URL.revokeObjectURL(url); };
|
||||
audio.play();
|
||||
showToast('Preview: ' + voice, 'success');
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[VoicePrefs] Preview error:', err);
|
||||
showToast('Preview failed: ' + err.message, 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
if (btnPreview) {
|
||||
btnPreview.disabled = false;
|
||||
btnPreview.innerHTML = '<i class="fas fa-play"></i> Preview';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -1,729 +0,0 @@
|
|||
// ============================================================
|
||||
// WELL VISIT / PREVENTIVE CARE TAB
|
||||
// ============================================================
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ─── Human-readable labels ─────────────────────────────────────────────────
|
||||
var SCREEN_LABELS = {
|
||||
maternalDepression: 'Maternal/Caregiver Depression Screen (Edinburgh/PHQ)',
|
||||
developmentalScreening: 'Developmental Screening (ASQ / PEDS)',
|
||||
autismScreening: 'Autism Screening (M-CHAT-R)',
|
||||
developmentalSurveillance:'Developmental Surveillance',
|
||||
behavioralScreening: 'Social-Emotional/Behavioral Screening (ASQ:SE)',
|
||||
tobaccoAlcoholDrugs: 'Tobacco / Alcohol / Drug Use Screening (CRAFFT/AUDIT)',
|
||||
depressionSuicideRisk: 'Depression & Suicide Risk Screening (PHQ-A)',
|
||||
};
|
||||
|
||||
var PROC_LABELS = {
|
||||
newbornBlood: 'Newborn Blood Spot Screening (NBS)',
|
||||
newbornBilirubin: 'Newborn Bilirubin (TcB or TSB)',
|
||||
criticalCHD: 'Critical CHD Screening (Pulse Ox)',
|
||||
immunization: 'Immunizations Review & Update',
|
||||
anemia: 'Anemia Screening (Hgb/Hct)',
|
||||
lead: 'Lead Exposure Risk / Blood Lead Level',
|
||||
tuberculosis: 'Tuberculosis / Latent TB Risk Assessment',
|
||||
dyslipidemia: 'Dyslipidemia Screening (lipid panel)',
|
||||
sti: 'STI Screening (gonorrhea / chlamydia / syphilis)',
|
||||
hiv: 'HIV Screening',
|
||||
hepB: 'Hepatitis B Screening (HBsAg)',
|
||||
hepC: 'Hepatitis C Screening (anti-HCV)',
|
||||
suddenCardiacArrest:'Sudden Cardiac Arrest Risk Assessment',
|
||||
cervicalDysplasia: 'Cervical Dysplasia Screening (Pap smear)',
|
||||
};
|
||||
|
||||
var MEASURE_LABELS = {
|
||||
lengthHeight: 'Length / Height',
|
||||
weight: 'Weight',
|
||||
headCircumference: 'Head Circumference',
|
||||
weightForLength: 'Weight-for-Length',
|
||||
bmi: 'BMI',
|
||||
bloodPressure: 'Blood Pressure',
|
||||
};
|
||||
|
||||
var ORAL_LABELS = {
|
||||
assessment: 'Oral Health Risk Assessment',
|
||||
fluorideVarnish: 'Fluoride Varnish Application',
|
||||
fluorideSupplementation:'Fluoride Supplementation (if water <0.6 ppm)',
|
||||
};
|
||||
|
||||
var SENSORY_LABELS = {
|
||||
vision: 'Vision Screening',
|
||||
hearing: 'Hearing Screening',
|
||||
};
|
||||
|
||||
var VACCINE_FULL_NAMES = {
|
||||
HepB: 'Hepatitis B (HepB)',
|
||||
RV: 'Rotavirus (RV)',
|
||||
DTaP: 'DTaP (Diphtheria, Tetanus, Pertussis)',
|
||||
Hib: 'Hib (Haemophilus influenzae type b)',
|
||||
PCV: 'Pneumococcal (PCV)',
|
||||
IPV: 'Polio (IPV)',
|
||||
Flu_IIV: 'Influenza (IIV)',
|
||||
MMR: 'MMR (Measles, Mumps, Rubella)',
|
||||
VAR: 'Varicella (VAR)',
|
||||
HepA: 'Hepatitis A (HepA)',
|
||||
Tdap: 'Tdap (Tetanus, Diphtheria, Pertussis booster)',
|
||||
HPV: 'HPV (Human Papillomavirus)',
|
||||
MenACWY: 'Meningococcal ACWY (MenACWY)',
|
||||
MenB: 'Meningococcal B (MenB)',
|
||||
RSV_mAb: 'RSV Monoclonal Antibody (Nirsevimab/Beyfortus)',
|
||||
COVID: 'COVID-19',
|
||||
Dengue: 'Dengue (DEN4CYD / Dengvaxia)',
|
||||
Mpox: 'Mpox (JYNNEOS)',
|
||||
};
|
||||
|
||||
// ─── Module-level state for visit statuses ────────────────────────────────
|
||||
// keyed by visitId + '.' + itemKey — persisted to localStorage
|
||||
var _visitStatuses = {};
|
||||
|
||||
// SSHADESS is only relevant for 12+ year visits
|
||||
var SSHADESS_VISITS = ['12y','13y','14y','15y','16y','17y','18y','19y','20y','21y'];
|
||||
|
||||
function saveStatusesToStorage() {
|
||||
try { localStorage.setItem('ped_visit_statuses', JSON.stringify(_visitStatuses)); } catch(e) {}
|
||||
}
|
||||
|
||||
function clearCurrentVisit() {
|
||||
var visitId = document.getElementById('wv-visit-select').value;
|
||||
if (!visitId) return;
|
||||
Object.keys(_visitStatuses).forEach(function(k) {
|
||||
if (k.indexOf(visitId + '.') === 0) delete _visitStatuses[k];
|
||||
});
|
||||
saveStatusesToStorage();
|
||||
renderVisitPanel(visitId);
|
||||
if (typeof showToast === 'function') showToast('Visit statuses cleared', 'info');
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
function init() {
|
||||
// Restore statuses from localStorage
|
||||
try {
|
||||
var saved = localStorage.getItem('ped_visit_statuses');
|
||||
if (saved) _visitStatuses = JSON.parse(saved) || {};
|
||||
} catch(e) { _visitStatuses = {}; }
|
||||
|
||||
populateVisitSelect();
|
||||
renderCatchUp();
|
||||
renderFullSchedule();
|
||||
|
||||
document.getElementById('wv-visit-select').addEventListener('change', onVisitChange);
|
||||
document.querySelectorAll('.wv-subtab-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
switchSubtab(btn.dataset.subtab);
|
||||
if (window.UIState) window.UIState.set('wv.subtab', btn.dataset.subtab);
|
||||
});
|
||||
});
|
||||
// Restore persisted subtab so the user lands on the last-open pane.
|
||||
if (window.UIState) {
|
||||
var savedSubtab = window.UIState.get('wv.subtab');
|
||||
if (savedSubtab) switchSubtab(savedSubtab);
|
||||
}
|
||||
|
||||
// Wire the visit-detail panel click/input ONCE here (not inside renderVisitPanel)
|
||||
// which would accumulate a new listener on every visit change — causing "can't click Done"
|
||||
var panel = document.getElementById('wv-visit-detail');
|
||||
if (panel) {
|
||||
panel.addEventListener('click', function (e) {
|
||||
var visitId = document.getElementById('wv-visit-select').value;
|
||||
var btn = e.target.closest('.visit-status-btn');
|
||||
if (btn) { handleVisitStatusClick(btn, visitId); saveStatusesToStorage(); }
|
||||
if (e.target.closest('#btn-wv-copy-to-note')) copyVisitStatusesToNote(visitId);
|
||||
if (e.target.closest('#btn-wv-clear-visit')) clearCurrentVisit();
|
||||
});
|
||||
panel.addEventListener('input', function (e) {
|
||||
var noteInput = e.target.closest('.visit-note-input');
|
||||
if (noteInput) {
|
||||
var sk = noteInput.dataset.statusKey;
|
||||
if (!_visitStatuses[sk] || typeof _visitStatuses[sk] === 'string') {
|
||||
_visitStatuses[sk] = { status: _visitStatuses[sk] || '', note: '' };
|
||||
}
|
||||
_visitStatuses[sk].note = noteInput.value;
|
||||
saveStatusesToStorage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show first real visit by default
|
||||
var sel = document.getElementById('wv-visit-select');
|
||||
if (sel && sel.value) onVisitChange();
|
||||
}
|
||||
|
||||
function populateVisitSelect() {
|
||||
var sel = document.getElementById('wv-visit-select');
|
||||
if (!sel || typeof VISIT_AGES === 'undefined') return;
|
||||
var grouped = {};
|
||||
VISIT_AGES.forEach(function (v) {
|
||||
if (!grouped[v.era]) grouped[v.era] = [];
|
||||
grouped[v.era].push(v);
|
||||
});
|
||||
var eraNames = {
|
||||
prenatal: 'Prenatal',
|
||||
infancy: 'Infancy (0–12 mo)',
|
||||
earlyChildhood: 'Early Childhood (1–5 y)',
|
||||
middleChildhood: 'Middle Childhood (6–11 y)',
|
||||
adolescence: 'Adolescence (11–21 y)',
|
||||
};
|
||||
Object.keys(grouped).forEach(function (era) {
|
||||
var og = document.createElement('optgroup');
|
||||
og.label = eraNames[era] || era;
|
||||
grouped[era].forEach(function (v) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v.id;
|
||||
opt.textContent = v.label;
|
||||
og.appendChild(opt);
|
||||
});
|
||||
sel.appendChild(og);
|
||||
});
|
||||
// select newborn by default
|
||||
sel.value = 'newborn';
|
||||
}
|
||||
|
||||
function onVisitChange() {
|
||||
var visitId = document.getElementById('wv-visit-select').value;
|
||||
if (!visitId) return;
|
||||
// Share selected visit age globally so other tabs can use it
|
||||
var sel = document.getElementById('wv-visit-select');
|
||||
var selectedOption = sel ? sel.options[sel.selectedIndex] : null;
|
||||
window._wellVisitAge = selectedOption ? selectedOption.textContent : visitId;
|
||||
try { sessionStorage.setItem('ped_visit_age', window._wellVisitAge); } catch(e) {}
|
||||
renderVisitPanel(visitId);
|
||||
// Show SSHADESS subtab only for age 12+ visits
|
||||
var shadessBtn = document.querySelector('.wv-subtab-btn[data-subtab="shadess"]');
|
||||
if (shadessBtn) {
|
||||
var showShadess = SSHADESS_VISITS.indexOf(visitId) !== -1;
|
||||
shadessBtn.style.display = showShadess ? '' : 'none';
|
||||
// If SSHADESS was active but should now be hidden, fall back to byvisit
|
||||
if (!showShadess && shadessBtn.classList.contains('active')) {
|
||||
switchSubtab('byvisit');
|
||||
}
|
||||
}
|
||||
// Milestones subtab is always shown (not age-gated)
|
||||
var milestonesBtn = document.querySelector('.wv-subtab-btn[data-subtab="milestones"]');
|
||||
if (milestonesBtn) {
|
||||
milestonesBtn.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function switchSubtab(name) {
|
||||
document.querySelectorAll('.wv-subtab-btn').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.subtab === name);
|
||||
});
|
||||
(document.getElementById('wellvisit-tab') || document).querySelectorAll('.wv-subpanel').forEach(function (p) {
|
||||
p.classList.toggle('hidden', p.id !== 'wv-panel-' + name);
|
||||
});
|
||||
}
|
||||
window.wvSwitchSubtab = switchSubtab;
|
||||
|
||||
// ─── Map visit ID to GROWTH_REFERENCE key ─────────────────────────────────
|
||||
function getGrowthDataForVisit(visitId) {
|
||||
if (typeof GROWTH_REFERENCE === 'undefined') return null;
|
||||
// Direct match
|
||||
if (GROWTH_REFERENCE[visitId]) return GROWTH_REFERENCE[visitId];
|
||||
// Map visit IDs to growth reference keys
|
||||
var mapping = {
|
||||
'newborn': 'newborn', '3-5d': 'newborn',
|
||||
'1mo': '1mo', '2mo': '2mo', '4mo': '4mo', '6mo': '6mo', '9mo': '9mo',
|
||||
'12mo': '12mo', '15mo': '15mo', '18mo': '18mo',
|
||||
'24mo': '24mo', '30mo': '30mo',
|
||||
'3y': '3y', '4y': '4y', '5y': '5y',
|
||||
'6y': '6y_to_10y', '7y': '6y_to_10y', '8y': '6y_to_10y', '9y': '6y_to_10y', '10y': '6y_to_10y',
|
||||
'11y': '11y_to_14y', '12y': '11y_to_14y', '13y': '11y_to_14y', '14y': '11y_to_14y',
|
||||
'15y': '15y_to_21y', '16y': '15y_to_21y', '17y': '15y_to_21y', '18y': '15y_to_21y',
|
||||
'19y': '15y_to_21y', '20y': '15y_to_21y', '21y': '15y_to_21y'
|
||||
};
|
||||
var key = mapping[visitId];
|
||||
return key ? GROWTH_REFERENCE[key] : null;
|
||||
}
|
||||
|
||||
// ─── Map visit ID to REFLEXES_REFERENCE key ───────────────────────────────
|
||||
function getReflexDataForVisit(visitId) {
|
||||
if (typeof REFLEXES_REFERENCE === 'undefined') return null;
|
||||
if (REFLEXES_REFERENCE[visitId]) return REFLEXES_REFERENCE[visitId];
|
||||
var mapping = {
|
||||
'newborn': 'newborn', '3-5d': 'newborn',
|
||||
'1mo': '1mo', '2mo': '2mo', '4mo': '4mo', '6mo': '6mo', '9mo': '9mo',
|
||||
'12mo': '12mo', '15mo': '15mo', '18mo': '18mo',
|
||||
'24mo': '24mo', '30mo': '30mo',
|
||||
'3y': '3y_to_5y', '4y': '3y_to_5y', '5y': '3y_to_5y',
|
||||
'6y': '6y_to_10y', '7y': '6y_to_10y', '8y': '6y_to_10y', '9y': '6y_to_10y', '10y': '6y_to_10y',
|
||||
'11y': '11y_to_14y', '12y': '11y_to_14y', '13y': '11y_to_14y', '14y': '11y_to_14y',
|
||||
'15y': '15y_to_21y', '16y': '15y_to_21y', '17y': '15y_to_21y', '18y': '15y_to_21y',
|
||||
'19y': '15y_to_21y', '20y': '15y_to_21y', '21y': '15y_to_21y'
|
||||
};
|
||||
var key = mapping[visitId];
|
||||
return key ? REFLEXES_REFERENCE[key] : null;
|
||||
}
|
||||
|
||||
// ─── Color a reflex status chip ───────────────────────────────────────────
|
||||
function reflexStatusColor(status) {
|
||||
var s = (status || '').toLowerCase();
|
||||
if (s.indexOf('absent / integrated') !== -1 || s === 'absent' || s.indexOf('integrated') !== -1) return '#059669'; // green — expected integration
|
||||
if (s.indexOf('present') !== -1 && s.indexOf('lifelong') !== -1) return '#059669';
|
||||
if (s.indexOf('present') !== -1) return '#2563eb'; // blue — present and expected
|
||||
if (s.indexOf('peak') !== -1) return '#2563eb';
|
||||
if (s.indexOf('emerging') !== -1) return '#7c3aed'; // purple
|
||||
if (s.indexOf('fading') !== -1) return '#d97706'; // amber — in transition
|
||||
if (s.indexOf('transition') !== -1) return '#d97706';
|
||||
if (s.indexOf('down-going') !== -1) return '#059669';
|
||||
if (s.indexOf('up-going') !== -1) return '#d97706';
|
||||
if (s.indexOf('2+') !== -1) return '#2563eb';
|
||||
return '#475569';
|
||||
}
|
||||
|
||||
// ─── BY VISIT panel ───────────────────────────────────────────────────────
|
||||
function renderVisitPanel(visitId) {
|
||||
var data = (typeof PERIODICITY !== 'undefined') ? PERIODICITY[visitId] : null;
|
||||
var codes = (typeof WELL_VISIT_CODES !== 'undefined') ? WELL_VISIT_CODES[visitId] : null;
|
||||
var panel = document.getElementById('wv-visit-detail');
|
||||
if (!panel) return;
|
||||
|
||||
if (!data) {
|
||||
panel.innerHTML = '<p class="wv-empty">No data for this visit.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
// ── Billing codes ──
|
||||
if (codes) {
|
||||
html += '<div class="wv-section wv-billing">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-receipt"></i> Billing Codes</h4>';
|
||||
html += '<div class="wv-billing-grid">';
|
||||
html += '<div class="wv-billing-cell"><span class="wv-label">ICD-10</span><span class="wv-code-chip icd">' + esc(codes.icd10) + '</span></div>';
|
||||
html += '<div class="wv-billing-cell"><span class="wv-label">CPT</span><span class="wv-code-chip cpt">' + esc(codes.cpt) + '</span></div>';
|
||||
html += '<div class="wv-billing-desc">' + esc(codes.description) + '</div>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// ── Measurements ──
|
||||
var measDue = Object.keys(data.measurements || {}).filter(function (k) {
|
||||
return data.measurements[k] === 'dot' || data.measurements[k] === 'range';
|
||||
});
|
||||
if (measDue.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-ruler"></i> Measurements</h4>';
|
||||
html += '<div class="wv-chip-list">';
|
||||
measDue.forEach(function (k) {
|
||||
var isRange = data.measurements[k] === 'range';
|
||||
html += '<span class="wv-chip wv-chip-measure' + (isRange ? ' wv-chip-range' : '') + '">' + esc(MEASURE_LABELS[k] || k) + (isRange ? ' <em>(range)</em>' : '') + '</span>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// ── Vaccines ──
|
||||
if (data.vaccines && data.vaccines.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-syringe"></i> Vaccines Due</h4>';
|
||||
html += '<div class="wv-vax-list">';
|
||||
data.vaccines.forEach(function (v) {
|
||||
var fullName = VACCINE_FULL_NAMES[v.vaccine] || v.vaccine;
|
||||
var itemKey = 'vax_' + v.vaccine + '_d' + (v.dose || '');
|
||||
var statusKey = visitId + '.' + itemKey;
|
||||
var curStatus = _visitStatuses[statusKey] || '';
|
||||
html += '<div class="wv-vax-item" data-status-key="' + esc(statusKey) + '" data-item-type="vaccine" data-item-label="' + esc(fullName + (v.dose ? ' Dose ' + v.dose : '')) + '">';
|
||||
html += '<div class="wv-vax-name"><i class="fas fa-circle-dot wv-vax-dot"></i>' + esc(fullName) + '</div>';
|
||||
if (v.dose) html += '<span class="wv-vax-dose">Dose ' + esc(String(v.dose)) + '</span>';
|
||||
if (v.notes) html += '<div class="wv-vax-note"><i class="fas fa-circle-info"></i> ' + esc(v.notes) + '</div>';
|
||||
html += '<div class="wv-vax-status-row" style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px;">';
|
||||
html += renderVaxStatusBtns(statusKey, curStatus);
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// ── Screenings: sensory ──
|
||||
var sensoryItems = buildStatusItems(data.sensory || {}, SENSORY_LABELS);
|
||||
if (sensoryItems.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-eye"></i> Sensory Screens</h4>';
|
||||
html += renderScreenItems(sensoryItems, visitId);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── Screenings: developmental / behavioral ──
|
||||
var devItems = buildStatusItems(data.developmental || {}, SCREEN_LABELS);
|
||||
if (devItems.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-brain"></i> Developmental / Behavioral Screens</h4>';
|
||||
html += renderScreenItems(devItems, visitId);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── Procedures ──
|
||||
var procItems = buildStatusItems(data.procedures || {}, PROC_LABELS, ['immunization']);
|
||||
if (procItems.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-flask"></i> Labs & Procedures</h4>';
|
||||
html += renderScreenItems(procItems, visitId);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── Oral Health ──
|
||||
var oralItems = buildStatusItems(data.oralHealth || {}, ORAL_LABELS);
|
||||
if (oralItems.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-tooth"></i> Oral Health</h4>';
|
||||
html += renderScreenItems(oralItems, visitId);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── Growth Reference & Feeding Guidance ──
|
||||
var growthData = getGrowthDataForVisit(visitId);
|
||||
if (growthData) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-chart-line"></i> Expected Growth</h4>';
|
||||
html += '<div class="wv-growth-box">';
|
||||
if (growthData.weight) html += '<div class="wv-growth-item"><span class="wv-growth-label">⚖️ Weight</span><span>' + esc(growthData.weight) + '</span></div>';
|
||||
if (growthData.length) html += '<div class="wv-growth-item"><span class="wv-growth-label">📏 Length/Height</span><span>' + esc(growthData.length) + '</span></div>';
|
||||
if (growthData.headCirc) html += '<div class="wv-growth-item"><span class="wv-growth-label">🧠 Head Circumference</span><span>' + esc(growthData.headCirc) + '</span></div>';
|
||||
html += '</div>';
|
||||
|
||||
if (growthData.feeding && growthData.feeding.length) {
|
||||
html += '<h4 class="wv-section-title" style="margin-top:8px;"><i class="fas fa-utensils"></i> Feeding & Nutrition Guidance</h4>';
|
||||
html += '<ul class="wv-feeding-list">';
|
||||
growthData.feeding.forEach(function (f) {
|
||||
html += '<li>' + esc(f) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── Expected Reflexes ──
|
||||
var reflexData = getReflexDataForVisit(visitId);
|
||||
if (reflexData && reflexData.reflexes && reflexData.reflexes.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-hand-sparkles"></i> Expected Reflexes</h4>';
|
||||
if (reflexData.intro) {
|
||||
html += '<div style="font-size:12px;color:var(--g600);margin-bottom:8px;line-height:1.5;">' + esc(reflexData.intro) + '</div>';
|
||||
}
|
||||
html += '<div class="wv-growth-box">';
|
||||
reflexData.reflexes.forEach(function (r) {
|
||||
var color = reflexStatusColor(r.status);
|
||||
html += '<div class="wv-growth-item" style="flex-direction:column;align-items:stretch;gap:4px;">';
|
||||
html += '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">';
|
||||
html += '<span class="wv-growth-label" style="min-width:0;">' + esc(r.name) + '</span>';
|
||||
html += '<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;background:' + color + '1a;color:' + color + ';border:1px solid ' + color + '66;">' + esc(r.status) + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div style="font-size:12px;color:var(--g700);line-height:1.5;">' + esc(r.note) + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── BMI / Weight Classification (ages 2+) ──
|
||||
if (growthData && growthData.bmiClassification && typeof BMI_CLASSIFICATION !== 'undefined') {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-weight-scale"></i> BMI / Weight Classification (AAP 2023)</h4>';
|
||||
html += '<div class="wv-bmi-table">';
|
||||
BMI_CLASSIFICATION.categories.forEach(function (cat) {
|
||||
html += '<div class="wv-bmi-row" style="border-left:4px solid ' + cat.color + ';">';
|
||||
html += '<div class="wv-bmi-label"><strong>' + esc(cat.label) + '</strong></div>';
|
||||
html += '<div class="wv-bmi-range">' + esc(cat.range) + '</div>';
|
||||
html += '<div class="wv-bmi-action">' + esc(cat.action) + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<div class="wv-bmi-note"><i class="fas fa-circle-info"></i> ' + esc(BMI_CLASSIFICATION.notes) + '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── Notes ──
|
||||
if (data.notes) {
|
||||
html += '<div class="wv-section wv-notes-box"><i class="fas fa-circle-info"></i> ' + esc(data.notes) + '</div>';
|
||||
}
|
||||
|
||||
// ── Action buttons ──
|
||||
html += '<div style="margin-top:12px;padding:8px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">';
|
||||
html += '<button class="btn-sm btn-primary" id="btn-wv-copy-to-note"><i class="fas fa-clipboard-check"></i> Copy to Visit Note</button>';
|
||||
html += '<button class="btn-sm btn-ghost" id="btn-wv-clear-visit" style="color:var(--red);"><i class="fas fa-rotate-left"></i> Clear This Visit</button>';
|
||||
html += '<span style="font-size:11px;color:var(--g400);">Copies vaccine + screening statuses to the Visit Note tab</span>';
|
||||
html += '</div>';
|
||||
|
||||
panel.innerHTML = html || '<p class="wv-empty">No specific recommendations found for this visit.</p>';
|
||||
// Note: click/input listeners are wired once in init() — NOT here
|
||||
}
|
||||
|
||||
function renderVaxStatusBtns(statusKey, curStatus) {
|
||||
var statuses = [
|
||||
{ value: 'Given', label: 'Given', cls: 'given' },
|
||||
{ value: 'Refused', label: 'Refused', cls: 'refused' },
|
||||
{ value: 'Deferred', label: 'Deferred', cls: 'deferred' },
|
||||
{ value: 'Already Done', label: 'Already Done', cls: 'already-done' },
|
||||
];
|
||||
return statuses.map(function (s) {
|
||||
var active = (typeof curStatus === 'object' ? curStatus.status : curStatus) === s.value;
|
||||
return '<button class="visit-status-btn' + (active ? ' ' + s.cls : '') + '" data-status-key="' + esc(statusKey) + '" data-status-val="' + esc(s.value) + '" data-btn-cls="' + s.cls + '">' + s.label + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderScreenStatusBtns(statusKey, curStatus) {
|
||||
var statuses = [
|
||||
{ value: 'Done', label: 'Done', cls: 'done' },
|
||||
{ value: 'Refused', label: 'Refused', cls: 'refused' },
|
||||
{ value: 'Not Due / N/A', label: 'Not Due / N/A', cls: 'not-due' },
|
||||
];
|
||||
return statuses.map(function (s) {
|
||||
var active = (typeof curStatus === 'object' ? curStatus.status : curStatus) === s.value;
|
||||
return '<button class="visit-status-btn' + (active ? ' ' + s.cls : '') + '" data-status-key="' + esc(statusKey) + '" data-status-val="' + esc(s.value) + '" data-btn-cls="' + s.cls + '">' + s.label + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function handleVisitStatusClick(btn, visitId) {
|
||||
var statusKey = btn.dataset.statusKey;
|
||||
var statusVal = btn.dataset.statusVal;
|
||||
var btnCls = btn.dataset.btnCls;
|
||||
if (!statusKey) return;
|
||||
|
||||
// Toggle: clicking same status deselects
|
||||
var cur = _visitStatuses[statusKey];
|
||||
var curVal = typeof cur === 'object' ? (cur ? cur.status : '') : (cur || '');
|
||||
var note = typeof cur === 'object' && cur ? (cur.note || '') : '';
|
||||
|
||||
if (curVal === statusVal) {
|
||||
_visitStatuses[statusKey] = { status: '', note: note };
|
||||
} else {
|
||||
_visitStatuses[statusKey] = { status: statusVal, note: note };
|
||||
}
|
||||
|
||||
// Update button highlight in this row
|
||||
var container = btn.closest('[data-status-key]') || btn.parentElement;
|
||||
// Find all visit-status-btn siblings with same statusKey
|
||||
var parent = btn.parentElement;
|
||||
parent.querySelectorAll('.visit-status-btn').forEach(function (b) {
|
||||
b.className = 'visit-status-btn';
|
||||
});
|
||||
var newVal = _visitStatuses[statusKey].status;
|
||||
if (newVal) {
|
||||
parent.querySelectorAll('.visit-status-btn').forEach(function (b) {
|
||||
if (b.dataset.statusVal === newVal) {
|
||||
b.classList.add(b.dataset.btnCls);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function copyVisitStatusesToNote(visitId) {
|
||||
var lines = [];
|
||||
|
||||
// Vaccines
|
||||
var vaxLines = [];
|
||||
Object.keys(_visitStatuses).forEach(function (k) {
|
||||
if (k.indexOf(visitId + '.vax_') === 0) {
|
||||
var itemEl = document.querySelector('[data-status-key="' + k + '"]');
|
||||
var label = itemEl ? itemEl.dataset.itemLabel : k.replace(visitId + '.vax_', '');
|
||||
var s = _visitStatuses[k];
|
||||
var status = typeof s === 'object' ? s.status : s;
|
||||
var note = typeof s === 'object' ? (s.note || '') : '';
|
||||
if (status) {
|
||||
vaxLines.push(label + ': ' + status + (note ? ' (' + note + ')' : ''));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (vaxLines.length) {
|
||||
lines.push('Vaccines:');
|
||||
vaxLines.forEach(function (l) { lines.push(' - ' + l); });
|
||||
}
|
||||
|
||||
// Screenings
|
||||
var screenLines = [];
|
||||
Object.keys(_visitStatuses).forEach(function (k) {
|
||||
if (k.indexOf(visitId + '.') === 0 && k.indexOf(visitId + '.vax_') !== 0) {
|
||||
var itemEl = document.querySelector('[data-status-key="' + k + '"]');
|
||||
var label = itemEl ? itemEl.dataset.itemLabel : k.replace(visitId + '.', '');
|
||||
var s = _visitStatuses[k];
|
||||
var status = typeof s === 'object' ? s.status : s;
|
||||
var note = typeof s === 'object' ? (s.note || '') : '';
|
||||
if (status) {
|
||||
screenLines.push(label + ': ' + status + (note ? ' (' + note + ')' : ''));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (screenLines.length) {
|
||||
lines.push('Screenings:');
|
||||
screenLines.forEach(function (l) { lines.push(' - ' + l); });
|
||||
}
|
||||
|
||||
if (!lines.length) {
|
||||
if (typeof showToast === 'function') showToast('No statuses selected yet. Click Done/Given/etc on each item first.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
var text = lines.join('\n');
|
||||
|
||||
// Put into wv-vaccines and wv-screenings textareas
|
||||
var vaxEl = document.getElementById('wv-vaccines');
|
||||
var screenEl = document.getElementById('wv-screenings');
|
||||
|
||||
if (vaxLines.length && vaxEl) {
|
||||
var existing = vaxEl.value.trim();
|
||||
vaxEl.value = existing ? existing + '\n' + vaxLines.join('\n') : vaxLines.join('\n');
|
||||
}
|
||||
if (screenLines.length && screenEl) {
|
||||
var existingS = screenEl.value.trim();
|
||||
screenEl.value = existingS ? existingS + '\n' + screenLines.join('\n') : screenLines.join('\n');
|
||||
}
|
||||
|
||||
// Also copy SSHADESS assessment if present
|
||||
var shadessText = document.getElementById('shadess-result-text');
|
||||
var wvShadessEl = document.getElementById('wv-shadess-text');
|
||||
if (shadessText && wvShadessEl) {
|
||||
var shadessContent = (shadessText.innerText || shadessText.textContent || '').trim();
|
||||
if (shadessContent) {
|
||||
var existingSh = wvShadessEl.value.trim();
|
||||
wvShadessEl.value = existingSh ? existingSh + '\n\n' + shadessContent : shadessContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof showToast === 'function') showToast('Visit statuses copied to Visit Note tab', 'success');
|
||||
}
|
||||
|
||||
function buildStatusItems(obj, labels, exclude) {
|
||||
exclude = exclude || [];
|
||||
return Object.keys(obj).filter(function (k) {
|
||||
return obj[k] && obj[k] !== '' && exclude.indexOf(k) === -1;
|
||||
}).map(function (k) {
|
||||
return { key: k, status: obj[k], label: labels[k] || k };
|
||||
});
|
||||
}
|
||||
|
||||
function renderScreenItems(items, visitId) {
|
||||
var html = '<div class="wv-screen-list">';
|
||||
items.forEach(function (item) {
|
||||
var statusClass = item.status === 'dot' ? 'wv-status-dot' : item.status === 'range' ? 'wv-status-range' : 'wv-status-risk';
|
||||
var statusText = item.status === 'dot' ? 'Recommended' : item.status === 'range' ? 'In Range' : 'If at Risk';
|
||||
var statusKey = (visitId || 'visit') + '.' + item.key;
|
||||
var curStatus = _visitStatuses[statusKey] || {};
|
||||
var curVal = typeof curStatus === 'object' ? (curStatus.status || '') : (curStatus || '');
|
||||
var curNote = typeof curStatus === 'object' ? (curStatus.note || '') : '';
|
||||
|
||||
html += '<div class="wv-screen-item" data-status-key="' + esc(statusKey) + '" data-item-label="' + esc(item.label) + '">';
|
||||
html += '<span class="wv-status-badge ' + statusClass + '">' + statusText + '</span>';
|
||||
html += '<span class="wv-screen-name">' + esc(item.label) + '</span>';
|
||||
html += '<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto;">';
|
||||
html += renderScreenStatusBtns(statusKey, curVal);
|
||||
html += '<input class="visit-note-input" data-status-key="' + esc(statusKey) + '" type="text" placeholder="Notes..." style="font-size:11px;padding:2px 6px;border:1px solid var(--g300);border-radius:6px;width:120px;" value="' + esc(curNote) + '">';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ─── CATCH-UP panel ───────────────────────────────────────────────────────
|
||||
function renderCatchUp() {
|
||||
var panel = document.getElementById('wv-panel-catchup');
|
||||
if (!panel || typeof CATCH_UP_SCHEDULE === 'undefined') return;
|
||||
|
||||
var html = '<div class="wv-catchup-intro"><i class="fas fa-circle-info"></i> CDC 2025 Catch-up Schedule — minimum ages and intervals for each vaccine series. Applies when a child is behind on routine immunizations.</div>';
|
||||
|
||||
Object.keys(CATCH_UP_SCHEDULE).forEach(function (vaxKey) {
|
||||
var v = CATCH_UP_SCHEDULE[vaxKey];
|
||||
var fullName = VACCINE_FULL_NAMES[vaxKey] || vaxKey;
|
||||
html += '<div class="wv-catchup-card">';
|
||||
html += '<h4 class="wv-catchup-title"><i class="fas fa-syringe"></i> ' + esc(fullName) + '</h4>';
|
||||
if (v.minimumAgeForDose1) {
|
||||
html += '<div class="wv-catchup-min-age">Minimum age for dose 1: <strong>' + esc(v.minimumAgeForDose1) + '</strong></div>';
|
||||
}
|
||||
|
||||
if (v.series && v.series.length) {
|
||||
html += '<table class="wv-catchup-table"><thead><tr><th>Dose</th><th>Min Age</th><th>Min Interval from Prev</th><th>Notes</th></tr></thead><tbody>';
|
||||
v.series.forEach(function (s) {
|
||||
html += '<tr>';
|
||||
html += '<td><strong>Dose ' + esc(String(s.dose)) + '</strong></td>';
|
||||
html += '<td>' + esc(s.minimumAge || '—') + '</td>';
|
||||
html += '<td>' + esc(s.minimumIntervalToPrev || '—') + '</td>';
|
||||
html += '<td>' + esc(s.notes || '') + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
if (v.catchUpNotes) {
|
||||
var notes = Array.isArray(v.catchUpNotes) ? v.catchUpNotes : [v.catchUpNotes];
|
||||
html += '<ul class="wv-catchup-notes">';
|
||||
notes.forEach(function (n) {
|
||||
html += '<li>' + esc(n) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
panel.innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── FULL SCHEDULE table ──────────────────────────────────────────────────
|
||||
function renderFullSchedule() {
|
||||
var panel = document.getElementById('wv-panel-schedule');
|
||||
if (!panel || typeof PERIODICITY === 'undefined' || typeof VISIT_AGES === 'undefined') return;
|
||||
|
||||
// Collect all vaccine keys that appear in any visit
|
||||
var allVaxKeys = {};
|
||||
var visitIds = VISIT_AGES.map(function (v) { return v.id; }).filter(function (id) {
|
||||
return PERIODICITY[id] && PERIODICITY[id].vaccines;
|
||||
});
|
||||
|
||||
visitIds.forEach(function (id) {
|
||||
PERIODICITY[id].vaccines.forEach(function (v) { allVaxKeys[v.vaccine] = true; });
|
||||
});
|
||||
|
||||
var vaxKeys = Object.keys(allVaxKeys);
|
||||
var visitLabels = VISIT_AGES.filter(function (v) { return visitIds.indexOf(v.id) !== -1; });
|
||||
|
||||
var html = '<div class="wv-schedule-scroll">';
|
||||
html += '<table class="wv-schedule-table">';
|
||||
html += '<thead><tr><th class="wv-sched-vax-col">Vaccine</th>';
|
||||
visitLabels.forEach(function (v) {
|
||||
html += '<th class="wv-sched-age-col">' + esc(v.label) + '</th>';
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
vaxKeys.forEach(function (vaxKey) {
|
||||
var fullName = VACCINE_FULL_NAMES[vaxKey] || vaxKey;
|
||||
html += '<tr><td class="wv-sched-vax-name">' + esc(fullName) + '</td>';
|
||||
visitLabels.forEach(function (visit) {
|
||||
var vaxData = PERIODICITY[visit.id] && PERIODICITY[visit.id].vaccines;
|
||||
var match = vaxData && vaxData.find(function (v) { return v.vaccine === vaxKey; });
|
||||
if (match) {
|
||||
var dose = match.dose ? String(match.dose) : '•';
|
||||
var label = (typeof match.dose === 'number') ? '#' + dose : dose;
|
||||
html += '<td class="wv-sched-cell wv-sched-has" title="' + esc(match.notes || fullName + ' dose ' + dose) + '">' + esc(label) + '</td>';
|
||||
} else {
|
||||
html += '<td class="wv-sched-cell"></td>';
|
||||
}
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
html += '<p class="wv-sched-legend"><span class="wv-sched-leg-dot"></span> Dose number shown. Hover cells for notes. "#N" = routine dose N. "annual" / "seasonal" = per CDC guidance. "catch-up" = as needed.</p>';
|
||||
panel.innerHTML = html;
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ─── Wire up ───────────────────────────────────────────────────────────────
|
||||
var _inited = false;
|
||||
var _vaxInited = false;
|
||||
var _catchupInited = false;
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
var tab = e.detail.tab;
|
||||
if (tab === 'wellvisit' && !_inited) { _inited = true; init(); }
|
||||
if (tab === 'vaxschedule' && !_vaxInited) { _vaxInited = true; renderFullSchedule(); }
|
||||
if (tab === 'catchup' && !_catchupInited) { _catchupInited = true; renderCatchUp(); }
|
||||
});
|
||||
|
||||
})();
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
// ============================================================
|
||||
// WHISPER WORKER — runs @xenova/transformers in a Web Worker
|
||||
// Audio never leaves the device. Model cached in IndexedDB.
|
||||
// ============================================================
|
||||
|
||||
console.log('[WhisperWorker] Starting worker initialization');
|
||||
|
||||
// Load transformers.js from our server (bundled, no external CDN dependency)
|
||||
importScripts('/models/transformers.min.js');
|
||||
|
||||
var env = self.transformers.env;
|
||||
var pipeline = self.transformers.pipeline;
|
||||
|
||||
// Configure transformers.js to load models from our local server
|
||||
env.allowLocalModels = false;
|
||||
env.useBrowserCache = true;
|
||||
env.allowRemoteModels = false;
|
||||
env.localModelPath = '/models/';
|
||||
|
||||
console.log('[WhisperWorker] Transformers loaded, models path: /models/');
|
||||
|
||||
var _pipe = null;
|
||||
var _loadedModel = null;
|
||||
|
||||
async function load(modelName) {
|
||||
if (_pipe && _loadedModel === modelName) {
|
||||
console.log('[WhisperWorker] Model already loaded:', modelName);
|
||||
return;
|
||||
}
|
||||
console.log('[WhisperWorker] Loading model:', modelName);
|
||||
self.postMessage({ type: 'loading', model: modelName });
|
||||
|
||||
try {
|
||||
_pipe = await pipeline('automatic-speech-recognition', modelName, {
|
||||
progress_callback: function(p) {
|
||||
console.log('[WhisperWorker] Progress:', p.status, p.file, p.progress);
|
||||
if (p.status === 'downloading' || p.status === 'progress') {
|
||||
self.postMessage({ type: 'progress', file: p.file || '', progress: Math.round(p.progress || 0) });
|
||||
}
|
||||
if (p.status === 'done' || p.status === 'ready') {
|
||||
self.postMessage({ type: 'progress', file: p.file || '', progress: 100 });
|
||||
}
|
||||
}
|
||||
});
|
||||
_loadedModel = modelName;
|
||||
console.log('[WhisperWorker] Model loaded successfully');
|
||||
self.postMessage({ type: 'ready', model: modelName });
|
||||
} catch (err) {
|
||||
console.error('[WhisperWorker] Load error:', err);
|
||||
self.postMessage({ type: 'error', message: 'Model load failed: ' + err.message });
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('message', async function(e) {
|
||||
var d = e.data;
|
||||
console.log('[WhisperWorker] Message received:', d.type);
|
||||
|
||||
if (d.type === 'load') {
|
||||
try {
|
||||
await load(d.model || 'Xenova/whisper-tiny.en');
|
||||
} catch(err) {
|
||||
console.error('[WhisperWorker] Load error:', err);
|
||||
self.postMessage({ type: 'error', message: 'Load failed: ' + err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.type === 'transcribe') {
|
||||
try {
|
||||
await load(d.model || 'Xenova/whisper-tiny.en');
|
||||
console.log('[WhisperWorker] Starting transcription...');
|
||||
var result = await _pipe(d.audio, {
|
||||
language: 'english',
|
||||
task: 'transcribe',
|
||||
chunk_length_s: 30,
|
||||
stride_length_s: 5,
|
||||
return_timestamps: false
|
||||
});
|
||||
console.log('[WhisperWorker] Transcription complete');
|
||||
self.postMessage({ type: 'result', text: result.text.trim() });
|
||||
} catch(err) {
|
||||
console.error('[WhisperWorker] Transcribe error:', err);
|
||||
self.postMessage({ type: 'error', message: 'Transcription failed: ' + err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[WhisperWorker] Worker initialized');
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
// ============================================================
|
||||
// WHISPER WORKER V2 — Alternative loading using dynamic import
|
||||
// Loads transformers.js via script tag method instead of importScripts
|
||||
// ============================================================
|
||||
|
||||
console.log('[WhisperWorker] V2 Starting initialization');
|
||||
|
||||
// Alternative loading method that works with CSP
|
||||
var TRANSFORMERS_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js';
|
||||
|
||||
var _transformers = null;
|
||||
var _pipe = null;
|
||||
var _loadedModel = null;
|
||||
var _loading = false;
|
||||
|
||||
// Load transformers library using alternative method
|
||||
async function initTransformers() {
|
||||
if (_transformers) return _transformers;
|
||||
if (_loading) {
|
||||
// Wait for current load to complete
|
||||
while (_loading) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
return _transformers;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
console.log('[WhisperWorker] V2 Loading transformers via importScripts fallback...');
|
||||
|
||||
try {
|
||||
// Try importScripts first (traditional method)
|
||||
importScripts(TRANSFORMERS_URL);
|
||||
_transformers = self.transformers || transformers;
|
||||
console.log('[WhisperWorker] V2 Loaded via importScripts');
|
||||
} catch (err) {
|
||||
console.error('[WhisperWorker] V2 importScripts failed, trying alternative:', err.message);
|
||||
|
||||
// Fallback: Notify main thread to use server-side transcription
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
message: 'Browser Whisper blocked by CSP/network. Using server transcription instead.'
|
||||
});
|
||||
_loading = false;
|
||||
throw new Error('CDN blocked - use server transcription');
|
||||
}
|
||||
|
||||
if (!_transformers) {
|
||||
_loading = false;
|
||||
throw new Error('Transformers library did not load');
|
||||
}
|
||||
|
||||
_transformers.env.allowLocalModels = false;
|
||||
_transformers.env.useBrowserCache = true;
|
||||
_loading = false;
|
||||
|
||||
console.log('[WhisperWorker] V2 Transformers ready');
|
||||
return _transformers;
|
||||
}
|
||||
|
||||
async function loadModel(modelName) {
|
||||
if (_pipe && _loadedModel === modelName) {
|
||||
console.log('[WhisperWorker] V2 Model already loaded:', modelName);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WhisperWorker] V2 Loading model:', modelName);
|
||||
self.postMessage({ type: 'loading', model: modelName });
|
||||
|
||||
try {
|
||||
var T = await initTransformers();
|
||||
|
||||
_pipe = await T.pipeline('automatic-speech-recognition', modelName, {
|
||||
progress_callback: function(p) {
|
||||
console.log('[WhisperWorker] V2 Progress:', p.status, p.file, p.progress);
|
||||
if (p.status === 'downloading' || p.status === 'progress') {
|
||||
self.postMessage({
|
||||
type: 'progress',
|
||||
file: p.file || '',
|
||||
progress: Math.round(p.progress || 0)
|
||||
});
|
||||
}
|
||||
if (p.status === 'done' || p.status === 'ready') {
|
||||
self.postMessage({ type: 'progress', file: p.file || '', progress: 100 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_loadedModel = modelName;
|
||||
console.log('[WhisperWorker] V2 Model loaded successfully');
|
||||
self.postMessage({ type: 'ready', model: modelName });
|
||||
} catch (err) {
|
||||
console.error('[WhisperWorker] V2 Load error:', err);
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
message: 'Model load failed: ' + err.message + '. Try server transcription instead.'
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('message', async function(e) {
|
||||
var d = e.data;
|
||||
console.log('[WhisperWorker] V2 Message received:', d.type);
|
||||
|
||||
try {
|
||||
if (d.type === 'load') {
|
||||
await loadModel(d.model || 'Xenova/whisper-tiny.en');
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.type === 'transcribe') {
|
||||
await loadModel(d.model || 'Xenova/whisper-tiny.en');
|
||||
console.log('[WhisperWorker] V2 Starting transcription...');
|
||||
|
||||
var result = await _pipe(d.audio, {
|
||||
language: 'english',
|
||||
task: 'transcribe',
|
||||
chunk_length_s: 30,
|
||||
stride_length_s: 5,
|
||||
return_timestamps: false
|
||||
});
|
||||
|
||||
console.log('[WhisperWorker] V2 Transcription complete');
|
||||
self.postMessage({ type: 'result', text: result.text.trim() });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WhisperWorker] V2 Error:', err);
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
message: err.message || 'Worker error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[WhisperWorker] V2 Worker initialized');
|
||||
36
server.ts
36
server.ts
|
|
@ -170,30 +170,10 @@ try {
|
|||
}
|
||||
console.log('🔖 Build ID:', BUILD_ID);
|
||||
|
||||
// Template index.html on each request if the file changed (mtime-based).
|
||||
// Avoids a stale in-memory copy after static edits / script additions while
|
||||
// still being essentially free — just an fs.stat per request, zero template
|
||||
// rebuild when mtime hasn't changed.
|
||||
var INDEX_PATH = path.join(__dirname, 'public', 'index.html');
|
||||
var _indexCached = { mtime: 0, html: null };
|
||||
function getTemplatedIndex() {
|
||||
try {
|
||||
var st = fs.statSync(INDEX_PATH);
|
||||
if (st.mtimeMs === _indexCached.mtime && _indexCached.html) return _indexCached.html;
|
||||
var raw = fs.readFileSync(INDEX_PATH, 'utf8');
|
||||
_indexCached.html = raw.replace(
|
||||
/(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g,
|
||||
'$1$2?v=' + BUILD_ID + '$3'
|
||||
);
|
||||
_indexCached.mtime = st.mtimeMs;
|
||||
return _indexCached.html;
|
||||
} catch (e) {
|
||||
console.warn('[build-id] index.html read failed:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Prime once at boot so first request is fast
|
||||
getTemplatedIndex();
|
||||
// (The old vanilla-index templating used to live here — the React
|
||||
// bundle has its own hashed asset URLs so no runtime templating is
|
||||
// needed anymore. BUILD_ID is still exposed via /api/build for the
|
||||
// client to detect deploys.)
|
||||
|
||||
// Public endpoint for cache-bust debugging + build-info
|
||||
app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); });
|
||||
|
|
@ -232,11 +212,9 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
|||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
} else if (filePath.match(/\.(js|css)$/)) {
|
||||
// Short cache for JS/CSS — 1 hour
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
} else if (filePath.indexOf('/components/') !== -1) {
|
||||
// Component HTML — 1 hour cache
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
// Hashed Vite assets get long cache — 1 day. Filenames change
|
||||
// on every build so stale caches can never serve old code.
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, immutable');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const router = express.Router();
|
|||
// Load growth reference and BMI classification data
|
||||
let scheduleData: any;
|
||||
try {
|
||||
scheduleData = require('../../public/js/pediatricScheduleData');
|
||||
scheduleData = require('../data/pediatric-schedule-data');
|
||||
} catch (e) {
|
||||
scheduleData = {};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue