Finishes Bedside parity. Neonatal, Respiratory, Ventilation, Sepsis,
and Burns replace their LegacyPanel fallbacks. All 15 vanilla
Bedside sub-modules are now real React components.
shared/clinical/fenton.ts — second, higher-accuracy Fenton table
fentonLmsPeditools: 21 GA weeks × 2 sexes (22-42 in 1-week steps)
ported verbatim from public/js/bedside/neonatal.js:20-37. This is
the peditools-derived table that superseded the older hand-rounded
version (still used by the Growth Charts calculator). Adds
calcZNeonatal (|L|<0.001 threshold) + zToPercentileNeonatal
(Abramowitz & Stegun erf form) so neonatal output matches the
vanilla Bedside numbers to 3 decimal places.
neonatalAssess() returns { gaDecimal, gaClass, bwClass,
weightClass, expectedWeight, z, percentile, L, M, S }.
scripts/capture-calc-vectors.js + e2e/fixtures/calc-vectors.json
Adds 8 neonatal vectors (including the 40w5d male 3070g validated
case from the vanilla file's own comment: z = -1.42).
shared/clinical/fenton.test.ts — now covers neonatal too (110 total
vitest cases pass: 19 calculators, 21 fenton, 70 bilirubin).
client/src/pages/BedsidePanels2.tsx — five new panels
• NeonatalPanel — GA+weight+sex inputs drive the Fenton assessment
(gestational-age class, weight-for-GA class, birth-weight
category, Z-score, percentile, expected M). Full NRP pathway
cards (birth→HR<100→HR<60 escalation), NRP drug table scaled
by weight (epi IV/IO/ETT, NS bolus, D10), and 5-element Apgar
scorer with reassuring/moderately-depressed/severely-depressed
guidance.
• RespiratoryPanel — four sub-modes: Asthma (mild/moderate/
severe with full drug tables + "when to intubate" / ABG /
heliox clinical decision boxes), PRAM scorer (0-12),
Westley croup scorer (0-17 with severity-tiered treatment),
Bronchiolitis admission decision tree (age / SpO₂ / hydration
/ distress) with AAP "NOT recommended" list.
• VentilationPanel — target SpO₂ table by population, 6-step
escalation ladder (NC → FM → NRB → HFNC → NIV → intubation)
with live-scaled HFNC flow (1-2 L/kg/min), BVM how-to,
mechanical vent starting settings (TV, rate by age, PEEP,
FiO₂, I:E), gas-exchange adjustment table, and the
oxygenation-vs-ventilation mental model.
• SepsisPanel — Phoenix Sepsis Criteria (JAMA 2024), red-flag
list, age-banded workup + empirical therapy (neonate /
infant / child abx drugs keyed to weight), SSC 2020 first-hour
bundle (0-5 min recognize → >60 min refractory-shock
vasoactive), and resuscitation targets.
• BurnsPanel — full 19-region Lund-Browder age-adjusted table
(ported verbatim), with per-region % input + live TBSA
computation + override. Parkland formula (4 × kg × TBSA,
8h/16h split + per-hr rates), 4-2-1 maintenance, UOP targets,
pearl list (palm rule, no first-degree, analgesia, tetanus),
and ABA burn-center referral criteria.
client/src/pages/BedsidePanels.tsx — dispatch extended to all 15
pills. REAL_BEDSIDE_PANELS now contains every Bedside pill id, so
the legacy-link fallback is dead code that can be pruned later.
Client tsc + vite build + 110/110 vitest tests all green.
641 lines
51 KiB
TypeScript
641 lines
51 KiB
TypeScript
// ============================================================
|
||
// BEDSIDE PANELS (second batch) — neonatal, respiratory,
|
||
// ventilation, sepsis, burns. Completes parity with the 15
|
||
// vanilla Bedside sub-modules. Drug per-kg + max values ported
|
||
// byte-for-byte from public/js/bedside/<module>.js.
|
||
// ============================================================
|
||
|
||
import { useState } from 'react';
|
||
import { formatDose } from '@shared/clinical/calculators';
|
||
import { neonatalAssess, type Sex } from '@shared/clinical/fenton';
|
||
|
||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||
const label = 'block text-xs font-medium text-muted-foreground';
|
||
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
|
||
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
|
||
|
||
function Dose({ label: l }: { label: string }) {
|
||
const i = l.indexOf('(');
|
||
if (i < 0) return <span className="font-semibold">{l}</span>;
|
||
return <span><span className="font-semibold">{l.slice(0, i).trim()}</span>{' '}<span className="text-xs text-muted-foreground">{l.slice(i)}</span></span>;
|
||
}
|
||
|
||
function DrugTable({ children, notes = true }: { children: React.ReactNode; notes?: boolean }) {
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead><tr><th className={th}>Drug</th><th className={th}>Dose</th><th className={th}>Route</th>{notes && <th className={th}>Notes</th>}</tr></thead>
|
||
<tbody>{children}</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Row({ name, dose, route, notes }: { name: string; dose: React.ReactNode; route: string; notes?: string }) {
|
||
return (
|
||
<tr>
|
||
<td className={td + ' font-semibold'} dangerouslySetInnerHTML={{ __html: name }} />
|
||
<td className={td}>{dose}</td>
|
||
<td className={td + ' text-xs'}>{route}</td>
|
||
{notes !== undefined && <td className={td + ' text-xs text-muted-foreground'} dangerouslySetInnerHTML={{ __html: notes }} />}
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// ── Neonatal ────────────────────────────────────────────────
|
||
export function NeonatalPanel() {
|
||
const [weeks, setWeeks] = useState('');
|
||
const [days, setDays] = useState('0');
|
||
const [wtG, setWtG] = useState('');
|
||
const [sex, setSex] = useState<Sex>('male');
|
||
const [kgForNrp, setKgForNrp] = useState('');
|
||
const [apgarScores, setApgarScores] = useState<Record<string, number>>({ appearance: 2, pulse: 2, grimace: 2, activity: 2, respiration: 2 });
|
||
|
||
const weeksNum = Number.parseInt(weeks, 10);
|
||
const daysNum = Number.parseInt(days, 10) || 0;
|
||
const wtNum = Number.parseFloat(wtG);
|
||
const validAssess = Number.isFinite(weeksNum) && weeksNum >= 22 && weeksNum <= 44 && Number.isFinite(wtNum) && wtNum > 0;
|
||
const assess = validAssess ? neonatalAssess(weeksNum, daysNum, wtNum, sex) : null;
|
||
|
||
const kg = Number.parseFloat(kgForNrp);
|
||
const validKg = Number.isFinite(kg) && kg > 0;
|
||
const epiIvLow = validKg ? Math.round(kg * 0.01 * 100) / 100 : 0;
|
||
const epiIvHigh = validKg ? Math.round(kg * 0.03 * 100) / 100 : 0;
|
||
const epiEtLow = validKg ? Math.round(kg * 0.05 * 100) / 100 : 0;
|
||
const epiEtHigh = validKg ? Math.round(kg * 0.1 * 100) / 100 : 0;
|
||
const ns = validKg ? Math.round(kg * 10) : 0;
|
||
const d10 = validKg ? Math.round(kg * 2 * 10) / 10 : 0;
|
||
|
||
const apgarTotal = Object.values(apgarScores).reduce((s, v) => s + v, 0);
|
||
const apgarSeverity = apgarTotal >= 7 ? 'Reassuring' : apgarTotal >= 4 ? 'Moderately depressed' : 'Severely depressed';
|
||
const apgarColor = apgarTotal >= 7 ? 'text-green-600 bg-green-50' : apgarTotal >= 4 ? 'text-amber-600 bg-amber-50' : 'text-destructive bg-red-50';
|
||
const apgarGuidance = apgarTotal >= 7
|
||
? 'Routine newborn care. Continue reassessment. Repeat at 5 min.'
|
||
: apgarTotal >= 4
|
||
? 'Stimulate, clear airway, warm. Give O₂ if cyanotic. Ventilate with PPV if HR <100 or apneic/gasping. Reassess q30 sec.'
|
||
: 'Full NRP pathway — PPV immediately. Intubate if PPV ineffective. Chest compressions if HR <60. Epinephrine and volume per NRP.';
|
||
|
||
return (
|
||
<section className={card} data-testid="bedside-panel-neonatal">
|
||
<h2 className="text-lg font-semibold">Neonatal Assessment + NRP + Apgar</h2>
|
||
|
||
{/* Assessment */}
|
||
<h3 className="text-sm font-semibold">Gestational age + size assessment (Fenton 2013)</h3>
|
||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4 max-w-xl">
|
||
<div><label className={label}>GA weeks</label><input type="number" min="22" max="44" className={input} value={weeks} onChange={(e) => setWeeks(e.target.value)} data-testid="neo-weeks" /></div>
|
||
<div><label className={label}>GA days (0-6)</label><input type="number" min="0" max="6" className={input} value={days} onChange={(e) => setDays(e.target.value)} data-testid="neo-days" /></div>
|
||
<div><label className={label}>Birth wt (g)</label><input type="number" min="200" max="7000" className={input} value={wtG} onChange={(e) => setWtG(e.target.value)} data-testid="neo-weight" /></div>
|
||
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)} data-testid="neo-sex"><option value="male">Male</option><option value="female">Female</option></select></div>
|
||
</div>
|
||
{assess && (
|
||
<div className="grid gap-3 sm:grid-cols-2" data-testid="neo-result">
|
||
<div className="rounded-md border p-3" style={{ borderColor: assess.gaClass.color + '55', background: assess.gaClass.color + '10' }}>
|
||
<div className="text-xs text-muted-foreground">Gestational Age</div>
|
||
<div className="text-base font-bold" style={{ color: assess.gaClass.color }}>{assess.gaClass.label}</div>
|
||
<div className="text-xs text-muted-foreground">{weeksNum} wk {daysNum} d ({assess.gaDecimal.toFixed(1)} wk)</div>
|
||
</div>
|
||
<div className="rounded-md border p-3" style={{ borderColor: assess.weightClass.color + '55', background: assess.weightClass.color + '10' }}>
|
||
<div className="text-xs text-muted-foreground">Weight for Gestational Age</div>
|
||
<div className="text-base font-bold" style={{ color: assess.weightClass.color }}>{assess.weightClass.label}</div>
|
||
<div className="text-xs text-muted-foreground">{assess.percentile.toFixed(1)}th percentile · {assess.weightClass.detail}</div>
|
||
</div>
|
||
<div className="rounded-md border p-3" style={{ borderColor: assess.bwClass.color + '55', background: assess.bwClass.color + '10' }}>
|
||
<div className="text-xs text-muted-foreground">Birth Weight Category</div>
|
||
<div className="text-base font-bold" style={{ color: assess.bwClass.color }}>{assess.bwClass.label}</div>
|
||
<div className="text-xs text-muted-foreground">{wtNum} g ({(wtNum / 1000).toFixed(2)} kg)</div>
|
||
</div>
|
||
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||
<div className="text-xs text-muted-foreground uppercase tracking-wide">Fenton ({sex})</div>
|
||
<div className="space-y-0.5 mt-1">
|
||
<div><strong>Expected weight (M):</strong> {assess.expectedWeight} g</div>
|
||
<div><strong>Z-score:</strong> {assess.z.toFixed(2)}</div>
|
||
<div><strong>Percentile:</strong> {assess.percentile.toFixed(1)}%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* NRP pathway */}
|
||
<h3 className="text-sm font-semibold mt-3">NRP pathway (AHA/AAP 8th ed 2020)</h3>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">BIRTH — ASSESS (first 30 sec)</div><div className="text-xs text-muted-foreground">Term? Tone? Breathing/crying? All yes → routine care. Any no → warm, dry, stimulate, clear airway PRN, evaluate HR + resp.</div></div>
|
||
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">HR <100 OR apneic/gasping (60 s)</div><div className="text-xs text-muted-foreground"><strong>Start PPV</strong> 40-60 breaths/min, room air for term / 21-30% for preterm. Attach SpO₂ (right hand) ± ECG. MR SOPA if ineffective.</div></div>
|
||
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">HR <100 after 30 s effective PPV</div><div className="text-xs text-muted-foreground">Reassess ventilation — ensure chest rise. Consider increasing FiO₂, intubation, or LMA. Continue PPV.</div></div>
|
||
<div className="rounded-md border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">HR <60 after 30 s effective PPV</div><div className="text-xs text-muted-foreground"><strong>Intubate + chest compressions</strong> — 3:1 ratio (90 compressions + 30 breaths/min), FiO₂ 100%, lower 1/3 sternum, depth 1/3 AP chest.</div></div>
|
||
<div className="rounded-md border-l-4 border-destructive bg-red-100 dark:bg-red-950/40 p-3"><div className="font-semibold">HR <60 despite compressions + PPV × 60 s</div><div className="text-xs text-muted-foreground"><strong>Epinephrine 1:10,000 (0.1 mg/mL):</strong> IV/IO 0.01-0.03 mg/kg (0.1-0.3 mL/kg) — preferred. ETT 0.05-0.1 mg/kg. Repeat q3-5 min. Hypovolemia: <strong>NS 10 mL/kg IV/IO over 5-10 min</strong>.</div></div>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
|
||
<div className="rounded-md bg-muted/40 p-2"><strong>Target SpO₂ (preductal):</strong><br />1 min 60-65% · 2 min 65-70% · 3 min 70-75% · 4 min 75-80% · 5 min 80-85% · 10 min 85-95%</div>
|
||
<div className="rounded-md bg-muted/40 p-2"><strong>Initial ETT size:</strong><br /><1 kg / <28 wk: 2.5 · 1-2 kg / 28-34 wk: 3.0 · 2-3 kg / 34-38 wk: 3.5 · >3 kg / >38 wk: 3.5-4.0</div>
|
||
<div className="rounded-md bg-muted/40 p-2"><strong>ETT depth (lip):</strong> ~6 + weight(kg) cm</div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold mt-3">NRP drug doses</h3>
|
||
<div className="max-w-xs"><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={kgForNrp} onChange={(e) => setKgForNrp(e.target.value)} data-testid="nrp-weight" /></div>
|
||
{validKg ? (
|
||
<DrugTable>
|
||
<Row name="Epinephrine 1:10,000" dose={<Dose label={`${epiIvLow}-${epiIvHigh} mg, ${Math.round(epiIvLow * 10) / 10}-${Math.round(epiIvHigh * 10) / 10} mL (0.01-0.03 mg/kg = 0.1-0.3 mL/kg)`} />} route="IV / IO" notes="Preferred route. Repeat q3-5 min." />
|
||
<Row name="Epinephrine 1:10,000" dose={<Dose label={`${epiEtLow}-${epiEtHigh} mg, ${Math.round(epiEtLow * 10) / 10}-${Math.round(epiEtHigh * 10) / 10} mL (0.05-0.1 mg/kg = 0.5-1 mL/kg)`} />} route="ETT" notes="While IV being placed." />
|
||
<Row name="Normal saline" dose={<Dose label={`${ns} mL (10 mL/kg)`} />} route="IV / IO" notes="Over 5-10 min for volume. Repeat PRN." />
|
||
<Row name="Dextrose 10%" dose={<Dose label={`${d10} mL (2 mL/kg = 0.2 g/kg)`} />} route="IV slow push" notes="For documented hypoglycemia. Then D10 infusion 4-6 mg/kg/min." />
|
||
</DrugTable>
|
||
) : <p className="text-xs text-destructive">Enter weight (kg) to see NRP doses.</p>}
|
||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||
<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>
|
||
|
||
{/* Apgar */}
|
||
<h3 className="text-sm font-semibold mt-3">Apgar score</h3>
|
||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-5 text-xs">
|
||
{[
|
||
['appearance', 'Appearance', ['Blue/pale', 'Body pink, extremities blue', 'All pink']],
|
||
['pulse', 'Pulse', ['Absent', '<100 bpm', '≥100 bpm']],
|
||
['grimace', 'Grimace', ['No response', 'Grimace', 'Cough/sneeze']],
|
||
['activity', 'Activity', ['Limp', 'Some flexion', 'Active motion']],
|
||
['respiration', 'Respiration', ['Absent', 'Slow/irregular', 'Good/crying']],
|
||
].map(([key, lab, options]) => (
|
||
<div key={key as string}>
|
||
<label className={label}>{lab}</label>
|
||
<select className={input} value={apgarScores[key as string]} onChange={(e) => setApgarScores({ ...apgarScores, [key as string]: Number(e.target.value) })} data-testid={'apgar-' + key}>
|
||
{(options as string[]).map((o, i) => <option key={i} value={i}>{i} — {o}</option>)}
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className={'rounded-md p-3 ' + apgarColor} data-testid="apgar-result">
|
||
<div className="text-base font-bold">Apgar: {apgarTotal}/10 — {apgarSeverity}</div>
|
||
<div className="text-xs text-muted-foreground mt-1">{apgarGuidance}</div>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground italic">
|
||
Fenton TR, Kim JH. BMC Pediatr 2013;13:59 · NRP 8th ed (AHA/AAP 2020) · Apgar is a description of status — <strong>never</strong> delay resuscitation while scoring.
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── Respiratory ─────────────────────────────────────────────
|
||
export function RespiratoryPanel() {
|
||
const [mode, setMode] = useState<'asthma' | 'pram' | 'croup' | 'bronch'>('asthma');
|
||
const [weight, setWeight] = useState('');
|
||
const wt = Number.parseFloat(weight);
|
||
const valid = Number.isFinite(wt) && wt > 0;
|
||
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
|
||
const [asthmaSev, setAsthmaSev] = useState<'mild' | 'moderate' | 'severe' | null>(null);
|
||
|
||
// PRAM inputs (0-12 total)
|
||
const [pram, setPram] = useState({ spo2: 0, retractions: 0, scalene: 0, air: 0, wheeze: 0 });
|
||
const pramTotal = Object.values(pram).reduce((s, v) => s + v, 0);
|
||
const pramSev = pramTotal <= 3 ? 'Mild' : pramTotal <= 7 ? 'Moderate' : 'Severe';
|
||
const pramColor = pramTotal <= 3 ? 'text-green-600 bg-green-50' : pramTotal <= 7 ? 'text-amber-600 bg-amber-50' : 'text-destructive bg-red-50';
|
||
|
||
// Croup / Westley (0-17)
|
||
const [croup, setCroup] = useState({ conscious: 0, cyanosis: 0, stridor: 0, air: 0, retractions: 0 });
|
||
const croupTotal = Object.values(croup).reduce((s, v) => s + v, 0);
|
||
const croupSev = croupTotal <= 2 ? 'Mild' : croupTotal <= 5 ? 'Moderate' : croupTotal <= 11 ? 'Severe' : 'Impending Respiratory Failure';
|
||
const croupColor = croupTotal <= 2 ? 'text-green-600 bg-green-50' : croupTotal <= 5 ? 'text-amber-600 bg-amber-50' : croupTotal <= 11 ? 'text-destructive bg-red-50' : 'text-red-900 bg-red-100';
|
||
|
||
// Bronchiolitis inputs
|
||
const [bronch, setBronch] = useState({ age: 'gte12w', spo2: 'ok', hydration: 'ok', distress: 'mild' });
|
||
const bronchAdmit = bronch.distress === 'severe' || bronch.spo2 === 'low' || bronch.hydration === 'poor' || bronch.age === 'lt12w';
|
||
|
||
return (
|
||
<section className={card} data-testid="bedside-panel-respiratory">
|
||
<h2 className="text-lg font-semibold">Respiratory</h2>
|
||
<div className="flex gap-2 flex-wrap">
|
||
{(['asthma', 'pram', 'croup', 'bronch'] as const).map((m) => (
|
||
<button key={m} type="button" onClick={() => setMode(m)} className={'px-3 py-1 rounded-full text-xs font-medium border ' + (mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')} data-testid={'resp-mode-' + m}>
|
||
{m === 'asthma' ? 'Asthma' : m === 'pram' ? 'PRAM' : m === 'croup' ? 'Croup (Westley)' : 'Bronchiolitis'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{mode !== 'pram' && mode !== 'bronch' && (
|
||
<div className="max-w-xs"><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="resp-weight" /></div>
|
||
)}
|
||
|
||
{/* ASTHMA */}
|
||
{mode === 'asthma' && (
|
||
<>
|
||
<div className="flex gap-2">
|
||
{(['mild', 'moderate', 'severe'] as const).map((s) => (
|
||
<button key={s} type="button" onClick={() => setAsthmaSev(s)} className={'px-3 py-1 rounded text-xs font-medium border ' + (asthmaSev === s ? (s === 'mild' ? 'bg-green-600 text-white' : s === 'moderate' ? 'bg-amber-500 text-white' : 'bg-destructive text-white') : 'bg-muted')}>
|
||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{asthmaSev && !valid && <p className="text-xs text-destructive">Enter weight (kg) to see doses.</p>}
|
||
{asthmaSev === 'mild' && valid && (
|
||
<>
|
||
<p className="text-xs text-muted-foreground">Speaks in sentences, no accessory muscle use, SpO₂ ≥94%</p>
|
||
<DrugTable>
|
||
<Row name="Albuterol (MDI)" dose="4-8 puffs via spacer" route="Inhaled" notes="q20min × 3 doses, then q1-4h" />
|
||
<Row name="Albuterol (neb)" dose={<Dose label={`${f(0.15, 5, 'mg')!.label} (min 2.5 mg)`} />} route="Nebulized" notes="q20min × 3 doses" />
|
||
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="PO/IV" notes="Single dose, or 2 days" />
|
||
<Row name="Prednisolone" dose={<Dose label={`${f(1, 60)!.label}/day`} />} route="PO" notes="Alternative: 3-5 day course" />
|
||
</DrugTable>
|
||
</>
|
||
)}
|
||
{asthmaSev === 'moderate' && valid && (
|
||
<>
|
||
<p className="text-xs text-muted-foreground">Speaks in phrases, some accessory muscle use, SpO₂ 90-93%</p>
|
||
<DrugTable>
|
||
<Row name="Albuterol (neb)" dose={<Dose label={`${f(0.15, 5, 'mg')!.label} (min 2.5 mg)`} />} route="Nebulized" notes="q20min × 3 doses, then continuous if needed" />
|
||
<Row name="Ipratropium" dose={wt < 20 ? '250 mcg' : '500 mcg'} route="Nebulized" notes="q20min × 3 doses with albuterol" />
|
||
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="PO/IV/IM" notes="Single dose" />
|
||
<Row name="O₂ supplemental" dose="Target SpO₂ ≥94%" route="NC/mask" notes="Titrate to effect" />
|
||
</DrugTable>
|
||
</>
|
||
)}
|
||
{asthmaSev === 'severe' && valid && (
|
||
<>
|
||
<p className="text-xs text-muted-foreground">Speaks in words only, significant accessory muscle use, SpO₂ <90%. Consider ICU.</p>
|
||
<DrugTable>
|
||
<Row name="Albuterol continuous" dose={<Dose label={`${f(0.5, 20, 'mg')!.label}/hr`} />} route="Continuous neb" notes="Or 0.15-0.3 mg/kg q20min" />
|
||
<Row name="Ipratropium" dose={wt < 20 ? '250 mcg' : '500 mcg'} route="Nebulized" notes="q20min × 3 doses with albuterol" />
|
||
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="IV" notes="Or methylprednisolone 2 mg/kg IV (max 60 mg)" />
|
||
<Row name="Magnesium sulfate" dose={<Dose label={`${f(50, 2000)!.label} IV over 20 min`} />} route="IV" notes="Single dose, monitor BP" />
|
||
<Row name="Epinephrine (IM)" dose={<Dose label={`${f(0.01, 0.5)!.label} (1:1000)`} />} route="IM" notes="If impending arrest / no IV access" />
|
||
<Row name="Terbutaline" dose={<Dose label={`${f(0.01, 0.4)!.label} SC/IV`} />} route="SC/IV" notes="Then 0.1-10 mcg/kg/min infusion" />
|
||
<Row name="O₂ supplemental" dose="Target SpO₂ ≥94%" route="High flow / NIPPV" notes="Consider BiPAP/CPAP" />
|
||
</DrugTable>
|
||
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-900 dark:text-red-100">
|
||
<strong>Continuous monitoring.</strong> Consider ICU admission. If no response to magnesium → terbutaline infusion. If impending respiratory failure → intubation (ketamine preferred induction agent).
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="text-xs text-muted-foreground italic">NAEPP/GINA guidelines. Always use clinical judgment.</div>
|
||
</>
|
||
)}
|
||
|
||
{/* PRAM */}
|
||
{mode === 'pram' && (
|
||
<>
|
||
<p className="text-sm text-muted-foreground">Pediatric Respiratory Assessment Measure (PRAM) — for asthma exacerbation severity (0-12).</p>
|
||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
|
||
{[
|
||
['spo2', 'SpO₂', ['≥95% (0)', '92-94% (1)', '<92% (2)']],
|
||
['retractions', 'Suprasternal retractions', ['Absent (0)', 'Present (2)']],
|
||
['scalene', 'Scalene muscle use', ['Absent (0)', 'Present (2)']],
|
||
['air', 'Air entry', ['Normal (0)', 'Mild ↓ at bases (1)', 'Widespread ↓ (2)', 'Absent/minimal (3)']],
|
||
['wheeze', 'Wheezing', ['Absent (0)', 'Expiratory only (1)', 'Ins+exp (2)', 'Audible without stethoscope/silent chest (3)']],
|
||
].map(([key, lab, options]) => (
|
||
<div key={key as string}>
|
||
<label className={label}>{lab}</label>
|
||
<select className={input} value={pram[key as keyof typeof pram]} onChange={(e) => setPram({ ...pram, [key as string]: Number(e.target.value) })} data-testid={'pram-' + key}>
|
||
{(options as string[]).map((o, i) => <option key={i} value={i}>{o}</option>)}
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className={'rounded-md p-3 ' + pramColor} data-testid="pram-result">
|
||
<div className="text-base font-bold">PRAM Score: {pramTotal}/12 — {pramSev}</div>
|
||
<div className="text-xs text-muted-foreground mt-1">Mild (0-3): outpatient management. Moderate (4-7): consider oral steroids + frequent bronchodilators. Severe (8-12): aggressive treatment, consider ICU.</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* CROUP */}
|
||
{mode === 'croup' && (
|
||
<>
|
||
<p className="text-sm text-muted-foreground">Westley croup score (0-17).</p>
|
||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
|
||
{[
|
||
['conscious', 'Level of consciousness', ['Normal (0)', 'Disoriented (5)']],
|
||
['cyanosis', 'Cyanosis', ['None (0)', 'With agitation (4)', 'At rest (5)']],
|
||
['stridor', 'Stridor', ['None (0)', 'With agitation (1)', 'At rest (2)']],
|
||
['air', 'Air entry', ['Normal (0)', 'Decreased (1)', 'Severely decreased (2)']],
|
||
['retractions', 'Retractions', ['None (0)', 'Mild (1)', 'Moderate (2)', 'Severe (3)']],
|
||
].map(([key, lab, options]) => (
|
||
<div key={key as string}>
|
||
<label className={label}>{lab}</label>
|
||
<select className={input} value={croup[key as keyof typeof croup]} onChange={(e) => setCroup({ ...croup, [key as string]: Number(e.target.value) })} data-testid={'croup-' + key}>
|
||
{(options as string[]).map((o, i) => <option key={i} value={i}>{o}</option>)}
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className={'rounded-md p-3 ' + croupColor} data-testid="croup-result">
|
||
<div className="text-base font-bold">Westley: {croupTotal}/17 — {croupSev}</div>
|
||
<div className="text-xs text-muted-foreground mt-1">Mild ≤2 · Moderate 3-5 · Severe 6-11 · Impending failure ≥12.</div>
|
||
</div>
|
||
{valid && (
|
||
<DrugTable>
|
||
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route={croupTotal <= 2 ? 'PO' : croupTotal <= 5 ? 'PO/IM' : 'IV/IM'} notes="Preferred corticosteroid; single dose" />
|
||
{croupTotal > 2 && <Row name="Racemic epinephrine" dose="0.5 mL of 2.25% solution" route="Nebulized" notes="May repeat q15-20min, observe 2-4 h" />}
|
||
{croupTotal > 2 && <Row name="Nebulized epinephrine" dose="0.5 mL/kg of 1:1000 (max 5 mL)" route="Nebulized" notes="Alternative to racemic" />}
|
||
{croupTotal > 5 && <Row name="Heliox" dose="70:30 or 80:20" route="Face mask" notes="Consider if not responding" />}
|
||
</DrugTable>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* BRONCHIOLITIS */}
|
||
{mode === 'bronch' && (
|
||
<>
|
||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 max-w-xl">
|
||
<div><label className={label}>Age</label><select className={input} value={bronch.age} onChange={(e) => setBronch({ ...bronch, age: e.target.value })}><option value="lt12w"><12 weeks (high risk)</option><option value="gte12w">≥12 weeks</option></select></div>
|
||
<div><label className={label}>SpO₂</label><select className={input} value={bronch.spo2} onChange={(e) => setBronch({ ...bronch, spo2: e.target.value })}><option value="ok">≥90%</option><option value="low"><90%</option></select></div>
|
||
<div><label className={label}>Hydration</label><select className={input} value={bronch.hydration} onChange={(e) => setBronch({ ...bronch, hydration: e.target.value })}><option value="ok">Adequate</option><option value="poor">Poor oral intake</option></select></div>
|
||
<div><label className={label}>Distress</label><select className={input} value={bronch.distress} onChange={(e) => setBronch({ ...bronch, distress: e.target.value })}><option value="mild">Mild</option><option value="moderate">Moderate</option><option value="severe">Severe</option></select></div>
|
||
</div>
|
||
<div className={'rounded-md p-3 ' + (bronchAdmit ? 'text-destructive bg-red-50' : 'text-green-600 bg-green-50')} data-testid="bronch-result">
|
||
<div className="text-base font-bold">{bronchAdmit ? 'Admit / Observe' : 'Likely Safe for Discharge'}</div>
|
||
{bronch.age === 'lt12w' && <div className="text-xs text-destructive mt-1">⚠ Age <12 weeks — high risk for apnea. Monitor closely.</div>}
|
||
</div>
|
||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||
<strong>NOT recommended (AAP 2014/2023):</strong> Albuterol/salbutamol (no benefit), epinephrine (no evidence), systemic corticosteroids (no benefit), antibiotics (unless bacterial co-infection), chest physiotherapy.
|
||
</div>
|
||
<div className="text-xs text-muted-foreground italic">AAP Clinical Practice Guideline: Management of Bronchiolitis in Infants and Children (2014, reaffirmed 2023). RSV most common (50-80%).</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── Ventilation (O₂ escalation + vent settings reference) ───
|
||
export function VentilationPanel() {
|
||
const [weight, setWeight] = useState('');
|
||
const [age, setAge] = useState('');
|
||
const wt = Number.parseFloat(weight);
|
||
const ageY = Number.parseFloat(age);
|
||
const validWt = Number.isFinite(wt) && wt > 0;
|
||
const hfLow = validWt ? Math.round(wt * 1 * 10) / 10 : 0;
|
||
const hfHigh = validWt ? Math.round(wt * 2 * 10) / 10 : 0;
|
||
const tvLow = validWt ? Math.round(wt * 6 * 10) / 10 : 0;
|
||
const tvHigh = validWt ? Math.round(wt * 8 * 10) / 10 : 0;
|
||
const hasAge = Number.isFinite(ageY) && ageY >= 0;
|
||
const rate = hasAge
|
||
? ageY < 0.1 ? '30-40'
|
||
: ageY < 1 ? '25-35'
|
||
: ageY < 5 ? '20-25'
|
||
: ageY < 12 ? '16-20'
|
||
: '12-16'
|
||
: '';
|
||
|
||
return (
|
||
<section className={card} data-testid="bedside-panel-ventilation">
|
||
<h2 className="text-lg font-semibold">O₂ & Ventilation</h2>
|
||
<div className="grid gap-2 grid-cols-2 max-w-md">
|
||
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="vent-weight" /></div>
|
||
<div><label className={label}>Age (years)</label><input type="number" min="0" step="0.5" className={input} value={age} onChange={(e) => setAge(e.target.value)} data-testid="vent-age" /></div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold">Target SpO₂</h3>
|
||
<DrugTable notes>
|
||
<Row name="Most children" dose="94-98%" route="—" notes="Normal" />
|
||
<Row name="Bronchiolitis (AAP 2014/2023)" dose="≥90%" route="—" notes="Don't chase higher saturations" />
|
||
<Row name="Chronic lung disease / CF" dose="90-94%" route="—" notes="Avoid hyperoxia in CO₂ retainers" />
|
||
<Row name="Preterm neonate" dose="90-95%" route="—" notes="Minimize ROP risk" />
|
||
<Row name="Term neonate (min of life)" dose="Per NRP ladder" route="—" notes="1 min 60-65% · 10 min 85-95%" />
|
||
</DrugTable>
|
||
|
||
<h3 className="text-sm font-semibold mt-2">Escalation ladder</h3>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="rounded-md border-l-4 border-green-500 bg-green-50 dark:bg-green-950/30 p-3"><div className="font-semibold">1. Nasal cannula (low-flow)</div><div className="text-xs text-muted-foreground"><strong>0.5-6 L/min</strong> · FiO₂ ~24-40% · comfortable, no humidification. Good for mild hypoxia.</div></div>
|
||
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">2. Simple face mask</div><div className="text-xs text-muted-foreground"><strong>6-10 L/min</strong> · FiO₂ 35-60%. Must keep flow >6 L/min to flush CO₂.</div></div>
|
||
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">3. Non-rebreather mask</div><div className="text-xs text-muted-foreground"><strong>10-15 L/min</strong> · FiO₂ 60-90%. Reservoir bag must stay inflated.</div></div>
|
||
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">4. High-flow nasal cannula (HFNC)</div><div className="text-xs text-muted-foreground"><strong>{validWt ? `1-2 L/kg/min = ${hfLow}-${hfHigh} L/min` : '1-2 L/kg/min'}</strong> · heated + humidified · FiO₂ 30-100% titratable · generates ~2-5 cmH₂O PEEP. Reassess at 1-2 h.</div></div>
|
||
<div className="rounded-md border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">5. Non-invasive (CPAP / BiPAP)</div><div className="text-xs text-muted-foreground">CPAP 5-10 cmH₂O · BiPAP IPAP 10-14 / EPAP 5. Needs cooperative patient, intact airway reflexes, no copious secretions.</div></div>
|
||
<div className="rounded-md border-l-4 border-destructive bg-red-100 dark:bg-red-950/40 p-3"><div className="font-semibold">6. Intubate + mechanical ventilation</div><div className="text-xs text-muted-foreground">When NIV fails, airway compromised, apnea, or GCS ≤8. See Airway tab for RSI drugs.</div></div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold mt-2">Bag-Valve-Mask (BVM)</h3>
|
||
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-3 text-xs space-y-1">
|
||
<div><strong>When:</strong> apnea, bradycardia (HR <60 neonate; inadequate breathing at any age), during resuscitation.</div>
|
||
<div><strong>Rate:</strong> Newborn 40-60/min · Infant-child 20-30/min · Adolescent 10-12/min (1 breath q5-6 sec).</div>
|
||
<div><strong>Tidal volume:</strong> 6-8 mL/kg — gentle chest rise only. Avoid over-ventilation.</div>
|
||
<div><strong>Technique:</strong> head tilt / jaw thrust, E-C or 2-thumb mask seal, squeeze 1 sec, release fully.</div>
|
||
<div><strong>Not ventilating?</strong> MR SOPA — Mask reseal, Reposition airway, Suction, Open mouth, Pressure ↑, Alternative airway.</div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold mt-2">Mechanical vent — starting settings</h3>
|
||
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
|
||
<div><strong>Mode:</strong> Volume-control OR Pressure-control. PRVC / SIMV-PS hybrids.</div>
|
||
<div><strong>Tidal volume:</strong> <strong>{validWt ? `${tvLow}-${tvHigh} mL` : '6-8 mL/kg'}</strong> (6-8 mL/kg). Use 4-6 mL/kg for ARDS.</div>
|
||
<div><strong>Rate:</strong> {rate ? `${rate}/min (age ${ageY} yr)` : 'Newborn 30-40 · Infant 25-35 · Child 16-20 · Adolescent 12-16'}.</div>
|
||
<div><strong>PEEP:</strong> start 5 cmH₂O. Increase to 8-12+ for refractory hypoxia.</div>
|
||
<div><strong>FiO₂:</strong> start 100%, wean rapidly to lowest that maintains target SpO₂.</div>
|
||
<div><strong>I:E ratio:</strong> 1:2 normally; 1:3-4 for obstructive disease.</div>
|
||
<div><strong>Plateau pressure:</strong> keep <30 cmH₂O (ideally <28).</div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold mt-2">Adjusting for gas exchange</h3>
|
||
<DrugTable notes>
|
||
<Row name="Low SpO₂ (oxygenation)" dose="↑ FiO₂" route="—" notes="Then ↑ PEEP (recruits collapsed alveoli)" />
|
||
<Row name="↑ PCO₂ (ventilation)" dose="↑ Rate" route="—" notes="Then ↑ Tidal volume" />
|
||
<Row name="↓ PCO₂ (over-ventilating)" dose="↓ Rate" route="—" notes="Then ↓ Tidal volume" />
|
||
<Row name="High peak pressure" dose="Check tube / compliance" route="—" notes="Suction, bronchodilator, lower TV" />
|
||
<Row name="Auto-PEEP (asthma, bronch)" dose="↓ Rate, ↑ Te" route="—" notes="Disconnect + bag briefly if critical" />
|
||
</DrugTable>
|
||
<div className="rounded-md bg-green-50 dark:bg-green-950/30 p-3 text-xs text-green-900 dark:text-green-100">
|
||
<strong>Mental model:</strong> Oxygenation is mostly <strong>FiO₂ + PEEP</strong>. Ventilation (CO₂) is mostly <strong>rate + tidal volume</strong>. Obstructive (asthma, bronchiolitis) → long expiratory time, permissive hypercapnia. Restrictive (ARDS) → low TV, high PEEP, permissive hypercapnia + hypoxia.
|
||
</div>
|
||
<div className="text-xs text-muted-foreground italic">AAP / PALS / AARC guidance.</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── Sepsis ──────────────────────────────────────────────────
|
||
export function SepsisPanel() {
|
||
const [weight, setWeight] = useState('');
|
||
const [age, setAge] = useState<'neonate' | 'infant' | 'child'>('child');
|
||
const wt = Number.parseFloat(weight);
|
||
const valid = Number.isFinite(wt) && wt > 0;
|
||
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
|
||
const ageLbl = age === 'neonate' ? 'Neonate (0-28 d)' : age === 'infant' ? 'Young infant (29 d - 3 mo)' : 'Older child / adolescent';
|
||
const bolus = valid ? Math.round(wt * 20) : null;
|
||
|
||
return (
|
||
<section className={card} data-testid="bedside-panel-sepsis">
|
||
<h2 className="text-lg font-semibold">Sepsis & Fever</h2>
|
||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 max-w-md">
|
||
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="sepsis-weight" /></div>
|
||
<div><label className={label}>Age band</label><select className={input} value={age} onChange={(e) => setAge(e.target.value as typeof age)} data-testid="sepsis-age"><option value="neonate">Neonate (0-28 d)</option><option value="infant">Infant (29 d - 3 mo)</option><option value="child">Older child / adolescent</option></select></div>
|
||
</div>
|
||
|
||
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive">
|
||
Sepsis approach — {ageLbl}{valid ? `, ${wt} kg` : ''}
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold mt-2">Definition — Phoenix Sepsis Criteria (JAMA 2024)</h3>
|
||
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
|
||
<div><strong>Sepsis</strong> = suspected or confirmed infection + Phoenix Score ≥2 (organ dysfunction across respiratory, cardiovascular, coagulation, neurological).</div>
|
||
<div><strong>Septic shock</strong> = sepsis + cardiovascular dysfunction (vasoactive support, or ↑lactate ≥5, or ↓MAP for age).</div>
|
||
<div className="text-muted-foreground italic">Previous SIRS-based criteria (Goldstein 2005) are now superseded.</div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold mt-2">Red flags</h3>
|
||
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-900 dark:text-red-100">
|
||
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 · Any immune compromise / indwelling line.
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold mt-2">Empirical therapy — {ageLbl}</h3>
|
||
<div className="rounded-md bg-muted/40 p-3 text-xs">
|
||
{age === 'neonate' && <><strong>Workup (full sepsis eval):</strong> CBC+diff, CRP, blood culture, UA+urine culture (cath), <strong>LP</strong> (CSF+HSV PCR), CXR if respiratory sx, procalcitonin. <strong>Early-onset</strong> (<72 h): GBS, E. coli, Listeria. <strong>Late-onset</strong> (>72 h): CoNS, S. aureus, gram-negs, Candida.</>}
|
||
{age === 'infant' && <><strong>Workup:</strong> Use validated rules — PECARN, Aronson, Rochester, Step-by-Step. CBC+ANC, procalcitonin/CRP, blood culture, UA+urine culture. Many warrant LP + admission + empiric abx. <strong>Coverage:</strong> GBS, E. coli, Listeria (up to ~6 wk), S. pneumo, N. meningitidis, H. flu, Salmonella.</>}
|
||
{age === 'child' && <><strong>Recognition:</strong> Phoenix score or clinical concern + suspected infection. <strong>Workup:</strong> CBC, CRP, procalcitonin, blood cx (+site-specific), lactate, blood gas, glucose, electrolytes, coags, LP if CNS concern. Source-directed imaging.</>}
|
||
</div>
|
||
|
||
{valid && (
|
||
<DrugTable>
|
||
{age === 'neonate' && <>
|
||
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="q8-12h. Covers GBS, Listeria, Enterococcus." />
|
||
<Row name="Gentamicin" dose={<Dose label={f(4, 120)!.label} />} route="IV" notes="q24-48h. Monitor levels." />
|
||
<Row name="Cefotaxime (add)" dose={<Dose label={f(50, 2000)!.label} />} route="IV" notes="If meningitis or gram-neg concern." />
|
||
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="HSV risk: maternal lesions, vesicles, seizures, CSF pleocytosis." />
|
||
</>}
|
||
{age === 'infant' && <>
|
||
<Row name="Ceftriaxone" dose={<Dose label={f(75, 2000)!.label} />} route="IV / IM" notes="q24h (100 mg/kg/day divided q12h for meningitis). <strong>Avoid <28 d</strong> if hyperbilirubinemia." />
|
||
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="If <6 wk: add for Listeria coverage." />
|
||
<Row name="Vancomycin" dose={<Dose label={f(15, 1000)!.label} />} route="IV" notes="If severe / MRSA risk / meningitis." />
|
||
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="<6 wk with suspicion of HSV." />
|
||
</>}
|
||
{age === 'child' && <>
|
||
<Row name="Ceftriaxone" dose={<Dose label={f(50, 2000)!.label} />} route="IV" notes="q24h (100 mg/kg/day divided for meningitis)." />
|
||
<Row name="Vancomycin" dose={<Dose label={f(15, 1000)!.label} />} route="IV" notes="q6h. If severe, indwelling line, or MRSA prevalence >10%." />
|
||
<Row name="Piperacillin-tazobactam" dose={<Dose label={f(100, 4500)!.label} />} route="IV" notes="If intra-abdominal / neutropenic." />
|
||
<Row name="Clindamycin" dose={<Dose label={f(10, 900)!.label} />} route="IV" notes="Adjunct for toxic shock syndrome (toxin suppression)." />
|
||
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="If HSV CNS concern." />
|
||
</>}
|
||
</DrugTable>
|
||
)}
|
||
|
||
<h3 className="text-sm font-semibold mt-2">First-hour bundle (SSC Peds 2020)</h3>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">0-5 min — Recognize</div><div className="text-xs text-muted-foreground">Screen, sepsis huddle/activation, ABCs, O₂ to SpO₂ >94%, warm.</div></div>
|
||
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">5-15 min — Access & labs</div><div className="text-xs text-muted-foreground">Two IVs or IO. Draw blood cx (ideally before abx), lactate, CBC, CMP, coags, blood gas, glucose. UA + culture. Source-specific cultures.</div></div>
|
||
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">15-30 min — Fluids</div><div className="text-xs text-muted-foreground">{valid ? <>NS or LR <strong>{bolus} mL</strong> bolus (20 mL/kg) over 5-10 min.</> : <>NS/LR 10-20 mL/kg bolus over 5-10 min.</>} Reassess HR, perfusion, lungs, liver. Repeat up to 40-60 mL/kg; stop if crackles/hepatomegaly.</div></div>
|
||
<div className="rounded-md border-l-4 border-green-500 bg-green-50 dark:bg-green-950/30 p-3"><div className="font-semibold">30-60 min — Antibiotics + reassess</div><div className="text-xs text-muted-foreground">Broad-spectrum empiric abx within 1 hour (≤1 h in septic shock). Recheck lactate, perfusion.</div></div>
|
||
<div className="rounded-md border-l-4 border-destructive bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">>60 min — Fluid-refractory shock</div><div className="text-xs text-muted-foreground">Start vasoactive (<strong>epinephrine 0.05-0.3 mcg/kg/min</strong> cold / <strong>norepinephrine 0.05-0.3 mcg/kg/min</strong> warm). Central/IO access. Stress-dose hydrocortisone {valid ? <><strong>{f(2, 100)!.value} mg</strong> IV (2 mg/kg, max 100 mg)</> : '2 mg/kg IV (max 100 mg)'} if catecholamine-resistant. ICU.</div></div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold mt-2">Resuscitation targets</h3>
|
||
<div className="rounded-md bg-muted/40 p-3 text-xs">
|
||
Normal mentation · Cap refill ≤2 s · Warm extremities · Strong peripheral pulses · UOP ≥1 mL/kg/hr · MAP ≥5th %ile for age (>65 mmHg adolescent) · SpO₂ ≥94% · Lactate trending down.
|
||
</div>
|
||
<div className="text-xs text-muted-foreground italic">Phoenix Sepsis Criteria (Schlapbach et al., JAMA 2024) · Surviving Sepsis Campaign Pediatric 2020 · AAP pediatric sepsis guidance.</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── Burns ───────────────────────────────────────────────────
|
||
// Lund-Browder age-adjusted region percentages ported VERBATIM from
|
||
// public/js/bedside/burns.js:10-30.
|
||
const LUND_BROWDER: Array<{ key: string; label: string; vals: [number, number, number, number, number]; ageSensitive?: boolean }> = [
|
||
{ key: 'head', label: 'Head', vals: [18, 13, 11, 9, 7], ageSensitive: true },
|
||
{ key: 'neck', label: 'Neck', vals: [2, 2, 2, 2, 2] },
|
||
{ key: 'ant_trunk', label: 'Anterior trunk', vals: [13, 13, 13, 13, 13] },
|
||
{ key: 'post_trunk', label: 'Posterior trunk', vals: [13, 13, 13, 13, 13] },
|
||
{ key: 'r_buttock', label: 'Right buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||
{ key: 'l_buttock', label: 'Left buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||
{ key: 'genital', label: 'Genitalia', vals: [1, 1, 1, 1, 1] },
|
||
{ key: 'r_uparm', label: 'R upper arm', vals: [4, 4, 4, 4, 4] },
|
||
{ key: 'l_uparm', label: 'L upper arm', vals: [4, 4, 4, 4, 4] },
|
||
{ key: 'r_forearm', label: 'R forearm', vals: [3, 3, 3, 3, 3] },
|
||
{ key: 'l_forearm', label: 'L forearm', vals: [3, 3, 3, 3, 3] },
|
||
{ key: 'r_hand', label: 'R hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||
{ key: 'l_hand', label: 'L hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||
{ key: 'r_thigh', label: 'R thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
|
||
{ key: 'l_thigh', label: 'L thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
|
||
{ key: 'r_leg', label: 'R lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
|
||
{ key: 'l_leg', label: 'L lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
|
||
{ key: 'r_foot', label: 'R foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
|
||
{ key: 'l_foot', label: 'L foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
|
||
];
|
||
const AGE_BANDS: Array<{ id: 'infant' | 'young' | 'child' | 'adol' | 'adult'; label: string }> = [
|
||
{ id: 'infant', label: 'Infant (<1 y)' },
|
||
{ id: 'young', label: 'Young child (1-5 y)' },
|
||
{ id: 'child', label: 'Child (5-10 y)' },
|
||
{ id: 'adol', label: 'Adolescent (10-15 y)' },
|
||
{ id: 'adult', label: 'Adult (>15 y)' },
|
||
];
|
||
|
||
export function BurnsPanel() {
|
||
const [weight, setWeight] = useState('');
|
||
const [ageBand, setAgeBand] = useState<'infant' | 'young' | 'child' | 'adol' | 'adult'>('young');
|
||
const [override, setOverride] = useState('');
|
||
const [pct, setPct] = useState<Record<string, number>>({});
|
||
|
||
const ageIdx = AGE_BANDS.findIndex((a) => a.id === ageBand);
|
||
const computedTbsa = LUND_BROWDER.reduce(
|
||
(sum, r) => sum + r.vals[ageIdx] * Math.min(100, Math.max(0, pct[r.key] ?? 0)) / 100,
|
||
0,
|
||
);
|
||
const tbsa = override.trim() ? Number(override) : Math.round(computedTbsa * 10) / 10;
|
||
const wt = Number.parseFloat(weight);
|
||
const validWt = Number.isFinite(wt) && wt > 0;
|
||
const validTbsa = Number.isFinite(tbsa) && tbsa > 0;
|
||
|
||
const total = validWt && validTbsa ? Math.round(4 * wt * tbsa) : 0;
|
||
const first8 = Math.round(total / 2);
|
||
const rateFirst = Math.round(first8 / 8);
|
||
const next16 = total - first8;
|
||
const rateNext = Math.round(next16 / 16);
|
||
const maint = validWt
|
||
? Math.round(wt <= 10 ? wt * 4 : wt <= 20 ? 40 + (wt - 10) * 2 : 60 + (wt - 20))
|
||
: 0;
|
||
|
||
return (
|
||
<section className={card} data-testid="bedside-panel-burns">
|
||
<h2 className="text-lg font-semibold">Burns — Lund-Browder + Parkland</h2>
|
||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-3 max-w-xl">
|
||
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="burn-weight" /></div>
|
||
<div><label className={label}>Age band</label><select className={input} value={ageBand} onChange={(e) => setAgeBand(e.target.value as typeof ageBand)} data-testid="burn-age">{AGE_BANDS.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}</select></div>
|
||
<div><label className={label}>TBSA override (%)</label><input type="number" min="0" max="100" step="1" className={input} value={override} onChange={(e) => setOverride(e.target.value)} placeholder={validTbsa ? String(tbsa) : 'auto'} data-testid="burn-tbsa" /></div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold">Body parts — % of each region burned (2° or deeper)</h3>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||
{LUND_BROWDER.map((r) => {
|
||
const max = r.vals[ageIdx];
|
||
return (
|
||
<div key={r.key} className="flex items-center gap-2 bg-muted/30 rounded p-2">
|
||
<label className="flex-1 text-xs">{r.label} <span className="text-muted-foreground">({max}%{r.ageSensitive ? '*' : ''})</span></label>
|
||
<input type="number" min="0" max="100" step="5" className="w-16 rounded border border-input bg-background px-2 py-1 text-xs text-right" value={pct[r.key] ?? 0} onChange={(e) => setPct({ ...pct, [r.key]: Number(e.target.value) })} data-testid={'burn-region-' + r.key} />
|
||
<span className="text-xs text-muted-foreground">%</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{validTbsa && <div className="text-sm text-muted-foreground">Computed TBSA: <strong>{tbsa}%</strong></div>}
|
||
{!validWt && <p className="text-xs text-destructive">Enter weight (kg) to see Parkland + maintenance fluids.</p>}
|
||
{!validTbsa && validWt && <p className="text-xs text-destructive">Enter % per region or override TBSA to compute fluids.</p>}
|
||
|
||
{validWt && validTbsa && (
|
||
<>
|
||
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive" data-testid="burn-result">
|
||
Burn fluid resuscitation — {wt} kg, {tbsa}% TBSA (2° or deeper)
|
||
</div>
|
||
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-sm space-y-1">
|
||
<div><strong>Parkland formula:</strong> 4 mL × kg × %TBSA = <strong>{total} mL LR over 24 hours</strong></div>
|
||
<div><strong>First 8 h</strong> (from time of burn): {first8} mL (~<strong>{rateFirst} mL/hr</strong>)</div>
|
||
<div><strong>Next 16 h:</strong> {next16} mL (~<strong>{rateNext} mL/hr</strong>)</div>
|
||
</div>
|
||
<div className="rounded-md bg-muted/40 p-3 text-sm">
|
||
<strong>Plus maintenance (4-2-1):</strong> {maint} mL/hr (D5 ½NS ± 20 mEq KCl/L once UOP established). Consider dextrose in children <30 kg.
|
||
</div>
|
||
<div className="rounded-md bg-muted/40 p-3 text-sm">
|
||
<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>
|
||
</>
|
||
)}
|
||
|
||
<h3 className="text-sm font-semibold">Other pearls</h3>
|
||
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
|
||
<div><strong>Rule of palm:</strong> Patient's palm + fingers ≈ 1% TBSA — good for scattered burns.</div>
|
||
<div><strong>First-degree burns DO NOT count</strong> toward TBSA or Parkland.</div>
|
||
<div><strong>Analgesia:</strong> Morphine 0.05-0.1 mg/kg IV q2h, or fentanyl 1-2 mcg/kg IV q30-60 min.</div>
|
||
<div><strong>Tetanus</strong> prophylaxis if indicated. Tdap/Td ± TIG.</div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold">Burn center referral (ABA)</h3>
|
||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||
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>
|
||
<div className="text-xs text-muted-foreground italic">
|
||
ABA Advanced Burn Life Support 2018 · Parkland formula: Baxter 1968 · Lund-Browder 1944.
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|