const P = window.UNIT_PROFILE || ODA_PROFILE;
const ODA_KEY = P.key;
const ODA_TWEAKS = /*EDITMODE-BEGIN*/{
"theme": "tactical",
"accent": "op",
"density": "regular",
"showSparklines": true,
"showGrid": true,
"timeMode": "both"
}/*EDITMODE-END*/;
const MECHANISMS = ['Artillery','Blast','Blunt','Burn','Chemical','Drone','Electrical','Fall','Grenade','GSW','IED','Landmine','MVC','RPG','Other'];
const ACUTE_FINDINGS = ['Impalement','Evisceration','Amputation','Open Chest','Sucking Chest','Flail Chest','Open Abdomen','Crush','Avulsion','Degloving','Penetrating Head','Spinal Injury'];
function loadTeam() {
const def = P.platoons.find(x => x.id === P.defaultPlatoon) || P.platoons[0];
const fresh = { team: def.team, platoonId: def.id, platoons: Object.fromEntries(P.platoons.filter(x => x.id !== def.id).map(x => [x.id, x.team])), selectedId: P.defaultSelected, opName: P.opName, medic: P.medic, teamSgt: P.leader, nine: {}, settings: { ...ODA_SETTINGS_DEFAULT, ...(P.settingsDefaults || {}) } };
try { const raw = localStorage.getItem(ODA_KEY); if (raw) { const s = JSON.parse(raw); if (s && Array.isArray(s.team) && s.team.length) { if (P.migrate) s.team = P.migrate(s.team); return { ...fresh, ...s, platoons: { ...fresh.platoons, ...(s.platoons || {}) }, platoonId: s.platoonId || fresh.platoonId, settings: { ...ODA_SETTINGS_DEFAULT, ...(s.settings || {}) } }; } } } catch (e) {}
return fresh;
}
function App() {
const [S, setS] = React.useState(loadTeam);
const [tab, setTab] = React.useState('march');
const [now, setNow] = React.useState(new Date());
const [t, setTweak] = useTweaks(ODA_TWEAKS);
const [drawer, setDrawer] = React.useState(null);
const focus = Math.min(3, Math.max(0, +S.focus || 0));
const setFocus = (v) => { odaSnd(v ? 'open' : 'close', st); setS(p => ({ ...p, focus: v })); };
const cycleFocus = () => setFocus((focus + 1) % 4);
const st = S.settings || ODA_SETTINGS_DEFAULT;
const setSt = (patch) => setS(p => ({ ...p, settings: { ...ODA_SETTINGS_DEFAULT, ...(p.settings || {}), ...patch } }));
const openDrawer = (k) => { odaSnd(k ? 'open' : 'close', st); setDrawer(k); };
React.useEffect(() => {
if (!st.tickAll || !st.sound) return;
const h = (e) => { if (e.target.closest('.dz, .dz-scrim')) return; if (e.target.closest('button, .chip, .fchip, .man, .rung, .proto .row, .cyc, .wbb .d, .tabs button')) odaSnd('tick', st); };
document.addEventListener('click', h, true);
return () => document.removeEventListener('click', h, true);
}, [st.tickAll, st.sound, st.vol]);
const ecgOn = !!(st.sound && st.ecgAudio);
React.useEffect(() => { try { localStorage.setItem('tccc-ecg-muted', ecgOn ? '0' : '1'); } catch (e) {} }, [ecgOn]);
React.useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', t.theme || 'tactical');
root.setAttribute('data-accent', t.accent || 'op');
root.setAttribute('data-density', t.density || 'regular');
root.style.setProperty('--grid-op', t.showGrid && t.theme !== 'paper' ? 0.18 : 0);
}, [t.theme, t.accent, t.density, t.showGrid]);
React.useEffect(() => { try { localStorage.setItem(ODA_KEY, JSON.stringify(S)); } catch (e) {} }, [S]);
React.useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []);
const team = S.team, mb = team.find(x => x.id === S.selectedId) || team[0];
const setTop = (k) => (v) => setS(p => ({ ...p, [k]: typeof v === 'function' ? v(p[k]) : v }));
const upd = (k, v) => setS(p => ({ ...p, team: p.team.map(x => x.id !== p.selectedId ? x : { ...x, [k]: typeof v === 'function' ? v(x[k]) : v }) }));
const set = (k) => (v) => upd(k, v);
const setMember = (fnOrObj) => setS(p => ({ ...p, team: p.team.map(x => x.id !== p.selectedId ? x : (typeof fnOrObj === 'function' ? fnOrObj(x) : { ...x, ...fnOrObj })) }));
const select = (id) => setS(p => ({ ...p, selectedId: id }));
const allPlatoons = P.platoons.map(pl => ({ id: pl.id, name: pl.name, team: pl.id === S.platoonId ? team : (S.platoons && S.platoons[pl.id]) || pl.team }));
const switchPlatoon = (id) => { if (id === S.platoonId) return; odaSnd('seg', st); setS(p => { const others = { ...(p.platoons || {}), [p.platoonId]: p.team }; const t = others[id] || (P.platoons.find(x => x.id === id) || {}).team || []; delete others[id]; return { ...p, team: t, platoonId: id, selectedId: (t.find(x => memberStatus(x) !== 'green') || t[0] || {}).id, platoons: others }; }); };
const teamAll = allPlatoons.flatMap(pl => pl.team);
const activeP = P.platoons.find(x => x.id === S.platoonId) || P.platoons[0];
const PA = { ...P, squads: activeP.squads || P.squads, billets: activeP.billets || P.billets };
const selectIn = (pid, id) => { if (pid === S.platoonId) { select(id); return; } setS(p => { const others = { ...(p.platoons || {}), [p.platoonId]: p.team }; const t = others[pid] || (P.platoons.find(x => x.id === pid) || {}).team || []; delete others[pid]; return { ...p, team: t, platoonId: pid, selectedId: id, platoons: others, companyView: false }; }); };
const setCompanyView = (v) => { odaSnd('seg', st); setS(p => ({ ...p, companyView: v })); };
const toggleIn = (k, val) => upd(k, arr => (arr||[]).includes(val) ? arr.filter(x => x !== val) : [...(arr||[]), val]);
const z = (n) => String(n).padStart(2, '0');
const zuluTime = `${z(now.getUTCHours())}${z(now.getUTCMinutes())}${z(now.getUTCSeconds())}Z`;
const localTime = `${z(now.getHours())}:${z(now.getMinutes())}:${z(now.getSeconds())}`;
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
const activeTQs = Object.values(mb.tourniquets).filter(q => q.active).length;
const lastVital = mb.vitals[mb.vitals.length - 1] || {};
const stat = memberStatus(mb);
const cas = team.filter(x => memberStatus(x) !== 'green').length;
const tbsa = estimateBurnTBSA(mb.injuries, mb.burnedRegions || []);
const nowMin = now.getHours() * 60 + now.getMinutes();
const dueMap = {};
team.forEach(x => {
if (memberStatus(x) === 'green') return;
const flags = [];
if (st.reminder) { const lv = x.vitals[x.vitals.length - 1]; const age = lv ? (nowMin - parseHHMM(lv.time) + 1440) % 1440 : Infinity; if (age >= st.interval) flags.push('VS DUE'); }
if (st.tqWarn) { Object.values(x.tourniquets || {}).forEach(q => { if (q.active && q.time) { const el = (nowMin - parseHHMM(q.time) + 1440) % 1440; if (el >= st.tqWarnMin && el < 120) flags.push('TQ 2H'); } }); }
if (flags.length) dueMap[x.id] = flags.join(' · ');
});
const dueAll = {};
if (teamAll.length > team.length) teamAll.forEach(x => { if (memberStatus(x) === 'green') return; const flags = []; if (st.reminder) { const lv = x.vitals[x.vitals.length - 1]; const age = lv ? (nowMin - parseHHMM(lv.time) + 1440) % 1440 : Infinity; if (age >= st.interval) flags.push('VS DUE'); } if (st.tqWarn) Object.values(x.tourniquets || {}).forEach(q => { if (q.active && q.time) { const el = (nowMin - parseHHMM(q.time) + 1440) % 1440; if (el >= st.tqWarnMin && el < 120) flags.push('TQ 2H'); } }); if (flags.length) dueAll[x.id] = flags.join(' · '); });
const dueKey = Object.entries(dueMap).map(([k, v]) => k + ':' + v).sort().join('|');
const alarmedRef = React.useRef('');
React.useEffect(() => {
if (!dueKey) { alarmedRef.current = ''; return; }
if (st.alarms && st.sound && alarmedRef.current !== dueKey) { alarmedRef.current = dueKey; odaSnd('alarm', st); }
}, [dueKey, st.alarms, st.sound]);
const redKey = team.filter(x => memberStatus(x) === 'red').map(x => x.id).join(',');
const prevRed = React.useRef(redKey);
React.useEffect(() => { if (st.autoSelectRed) { const fresh = redKey.split(',').filter(id => id && !prevRed.current.split(',').includes(id)); if (fresh.length) select(fresh[0]); } prevRed.current = redKey; }, [redKey]);
const confirmIf = (msg) => !st.confirmClear || confirm(msg);
const clearMember = () => { if (confirmIf(`Clear casualty data for ${mb.role} ${mb.name}? Profile (name, blood type, position) is kept.`)) setMember(x => ({ ...BLANK_MEMBER(), id: x.id, role: x.role, title: x.title, name: x.name, last4: x.last4, bloodType: x.bloodType, pos: x.pos, weight: x.weight, allergies: x.allergies })); };
const resetTeam = () => { if (confirmIf(P.resetConfirm)) { const keep = S.settings; localStorage.removeItem(ODA_KEY); setS({ ...loadTeam(), settings: keep }); setDrawer(null); } };
const blankTeam = () => { if (confirmIf(P.blankConfirm)) { const roles = PA.billets; const seen = {}; const pid = S.platoonId; const team = roles.map(([role, title], i) => { seen[role] = (seen[role] || 0) + 1; const dup = roles.filter(r => r[0] === role).length > 1; const id = PA.squads ? `${pid}${String(i + 1).padStart(2, '0')}` : (dup ? role + seen[role] : role); return { ...BLANK_MEMBER(), id, role, title, name: '', last4: '', bloodType: '—', pos: i + 1, weight: 0, unit: P.blankUnit || '', squad: PA.squads ? (PA.squads.findIndex(s => s.slots.includes(i))) : undefined }; }); setS(p => ({ ...p, team, selectedId: team[0].id, nine: {}, focus: 0 })); setDrawer(null); } };
const dueCount = Object.keys(dueMap).length;
const ageOf = (t) => { const m0 = parseHHMM(t); return isNaN(m0) ? null : (nowMin - m0 + 1440) % 1440; };
const injAge = mb.injuryTime ? ageOf(mb.injuryTime) : null;
const tqList = Object.entries(mb.tourniquets || {}).filter(([, q]) => q.active && q.time).map(([k, q]) => ({ side: ({ rArm: 'R ARM', lArm: 'L ARM', rLeg: 'R LEG', lLeg: 'L LEG' })[k], time: q.time, age: ageOf(q.time) })).sort((a, b) => (b.age || 0) - (a.age || 0));
const firstTq = tqList[0] || null, tqAge = firstTq ? firstTq.age : null;
const vsAge = lastVital.time ? ageOf(lastVital.time) : null;
const donors = team.filter(x => x.id !== mb.id && memberStatus(x) === 'green' && mb.bloodType && mb.bloodType !== '—' && CAN_GIVE(x.bloodType, mb.bloodType)).sort((a, b) => (a.bloodType === 'O-' ? -1 : 0) - (b.bloodType === 'O-' ? -1 : 0));
const autoLitter = mb.evac === 'urgent' || activeTQs > 0 || (mb.avpu && mb.avpu !== 'A');
const stepList = (team.some(x => memberStatus(x) !== 'green') ? team.filter(x => memberStatus(x) !== 'green') : team).sort((a, b) => a.pos - b.pos);
const stepIdx = stepList.findIndex(x => x.id === mb.id);
const step = (d) => { odaSnd('seg', st); const n = stepList.length; select(stepList[((stepIdx < 0 ? 0 : stepIdx) + d + n) % n].id); };
const [voiceOn, setVoiceOn] = React.useState(false);
const [hudOpen, setHudOpen] = React.useState(false);
const [interim, setInterim] = React.useState('');
const [vlog, setVlog] = React.useState([]);
const [vErr, setVErr] = React.useState('');
const undoRef = React.useRef([]);
const ctxRef = React.useRef({});
ctxRef.current = { team, mb, tab, focus, st, drawer, S, step, allPlatoons, platoonId: S.platoonId, selectIn };
const marchItems = React.useMemo(() => MARCH_PAWS.flatMap(sec => sec.groups.flatMap(g => g[1].map(it => sec.k + ':' + it))), []);
const logV = (heard, kind, did) => setVlog(l => [...l.slice(-30), { id: Date.now() + Math.random(), t: nowHHMM(), heard, kind, did }]);
const applyVoice = (cmds, heard) => {
const c = ctxRef.current;
if (!cmds.length) { logV(heard, 'warn', ''); if (c.st.voiceChime) odaSnd('off', c.st); return; }
undoRef.current = [...undoRef.current.slice(-9), c.S];
const did = [];
cmds.forEach(cmd => {
did.push(cmd.label);
switch (cmd.type) {
case 'focus': setFocus(cmd.level); break;
case 'step': c.step(cmd.d); break;
case 'drawer': setDrawer(cmd.k); break;
case 'tab': setTab(cmd.k); break;
case 'sound': setSt({ sound: cmd.on }); break;
case 'voiceOff': setVoiceOn(false); break;
case 'undo': { const prev = undoRef.current.pop(); undoRef.current.pop(); if (prev) setS(prev); break; }
case 'select': { const pl = c.allPlatoons && c.allPlatoons.find(pl => pl.team.some(x => x.id === cmd.id)); if (pl && pl.id !== c.platoonId) c.selectIn(pl.id, cmd.id); else select(cmd.id); break; }
case 'vitals': upd('draftVitals', d => ({ ...d, ...cmd.vit })); break;
case 'avpu': upd('avpu', cmd.v); break;
case 'recordVitals': setMember(x => { const d = x.draftVitals || {}; if (!(d.hr || d.sbp || d.rr || d.spo2)) return x; return { ...x, vitals: [...x.vitals, { time: nowHHMM(), hr: d.hr || '', sbp: d.sbp || '', dbp: d.dbp || '', rr: d.rr || '', spo2: d.spo2 || '', pain: d.pain || '' }] }; }); break;
case 'set': upd(cmd.k, cmd.v); break;
case 'toggleIn': upd(cmd.k, arr => { const a = arr || []; return cmd.on ? (a.includes(cmd.v) ? a : [...a, cmd.v]) : a.filter(v => v !== cmd.v); }); break;
case 'tq': upd('tourniquets', tq => ({ ...tq, [cmd.limb]: cmd.off ? { active: false, type: '', time: '', position: '' } : { active: true, type: cmd.tqType || tq[cmd.limb].type || 'CAT', time: cmd.time || tq[cmd.limb].time || nowHHMM(), position: cmd.pos || tq[cmd.limb].position || '' } })); break;
case 'med': upd('medsGiven', a => [...a, { id: Date.now(), time: nowHHMM(), name: cmd.name.replace(/\b\w/g, ch => ch.toUpperCase()), dose: cmd.dose }]); break;
case 'fluid': upd('fluidsGiven', a => [...a, { id: Date.now(), time: nowHHMM(), type: cmd.name.replace(/\b\w/g, ch => ch.toUpperCase()).replace(/^Lr$/, 'LR').replace(/Ltowb/, 'LTOWB').replace(/Prbc/, 'PRBC'), vol: cmd.vol }]); break;
case 'burn': upd('burn', b => ({ ...(b || {}), ...cmd.patch })); break;
case 'uop': upd('burn', b => ({ ...(b || {}), uop: [...((b && b.uop) || []), { t: nowHHMM(), ml: cmd.ml }] })); break;
case 'nine': setTop('nine')(n => ({ ...(n || {}), ['l' + cmd.n]: cmd.v })); break;
case 'note': upd('notes', n => (n ? n + '\n' : '') + `${nowHHMM()}: ${cmd.text}`); break;
}
});
logV(heard, 'ok', did.join(' · '));
if (c.st.voiceChime) odaSnd('log', c.st);
};
const onFinal = (text) => {
const c = ctxRef.current; let t = text.trim(); if (!t) return;
if (c.st.voiceWake) { const mm = t.match(/^\s*(?:hey |ok |okay )?medic[,.!]?\s*(.*)$/i); if (!mm) { logV(t, 'warn', ''); return; } t = mm[1]; }
applyVoice(parseVoice(t, { team: c.allPlatoons ? c.allPlatoons.flatMap(pl => pl.team) : c.team, marchItems, mechs: MECHANISMS, findings: ACUTE_FINDINGS }), t);
setInterim('');
};
const { supported: voiceSupported } = useVoice({ enabled: voiceOn, lang: st.voiceLang, onFinal, onInterim: setInterim, onError: (e) => { setVErr(e); if (e === 'not-allowed') setVoiceOn(false); } });
const toggleVoice = () => { if (!voiceSupported) { setHudOpen(true); return; } const v = !voiceOn; setVoiceOn(v); setVErr(''); if (v && st.voiceHud) setHudOpen(true); odaSnd(v ? 'open' : 'close', st); };
React.useEffect(() => {
if (!focus) return;
const h = (e) => { if (e.target.closest('input, textarea, select')) return; if (e.key === 'Escape') setFocus(0); else if (e.key === 'ArrowRight') step(1); else if (e.key === 'ArrowLeft') step(-1); else if (e.key === 'ArrowUp') setFocus(Math.min(3, focus + 1)); else if (e.key === 'ArrowDown') setFocus(Math.max(1, focus - 1)); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [focus, stepIdx, stepList.length]);
return (
<div className={'app oda ' + (P.appClass || '') + (focus ? ' focus focus-' + focus : '')}>
<div className="topbar">
<div className="brand"><span className="dot"></span>{P.brand}{allPlatoons.length > 1 && <span className="plt-suffix">· {P.teamName} · {(() => { const n = (allPlatoons.find(x => x.id === S.platoonId) || {}).name || ''; return /PLT|HQ/i.test(n) ? n : n + ' PLT'; })()}</span>}</div>
<div className="session">
<span>OP <input className="session-input" value={S.opName} onChange={e => setTop('opName')(e.target.value)} style={{ width: `${Math.max(8, (S.opName||'').length)}ch` }}/></span>
<span>{P.leaderLbl} <input className="session-input" value={S.teamSgt} onChange={e => setTop('teamSgt')(e.target.value)} style={{ width: `${Math.max(8, (S.teamSgt||'').length)}ch` }}/></span>
<span>{P.medicLbl} <input className="session-input" value={S.medic} onChange={e => setTop('medic')(e.target.value)} style={{ width: `${Math.max(8, (S.medic||'').length)}ch` }}/></span>
<span>DTG <b>{t.timeMode === 'local' ? `${dateStr} ${localTime}` : `${dateStr} ${zuluTime}`}</b></span>
<button className={'tb-btn' + (focus ? ' on' : '')} onClick={cycleFocus} onContextMenu={(e) => { e.preventDefault(); setFocus(0); }} title="Focus: ×1 casualty only · ×2 injury map central · ×3 map + treatment only (Esc exits · ↑↓ level · ←→ casualty)">⛶ {focus ? `FOCUS ×${focus}` : 'FOCUS'}</button>
<button className={'tb-btn' + (voiceOn ? ' listening' : '')} onClick={toggleVoice} onContextMenu={(e) => { e.preventDefault(); setHudOpen(h => !h); }} title={voiceSupported ? (voiceOn ? 'Listening — click to stop · right-click for HUD' : 'Voice control — click to start listening') : 'Speech recognition not available in this browser'}>◉ VOICE<span className="led"></span></button>
<button className={'tb-btn' + (st.sound ? ' on' : '')} onClick={() => { const v = !st.sound; setSt({ sound: v }); if (v) odaSnd('on', { ...st, sound: true }, true); }} title={st.sound ? `Sound on · ${Math.round(st.vol * 100)} % · click to mute` : 'Sound muted · click to enable'}>{st.sound ? '♪ SND' : '× MUTE'}</button>
<button className={'tb-btn' + (drawer === 'display' ? ' on' : '')} onClick={() => openDrawer(drawer === 'display' ? null : 'display')} title="Display: theme, accent, density, clock">◐ DISPLAY<span className="led" style={{ '--led': ({ op: 'var(--op)', amber: 'var(--amber)', crit: 'var(--crit)', info: 'var(--info)' })[t.accent] || 'var(--op)' }}></span></button>
<button className={'tb-btn' + (drawer === 'export' ? ' on' : '')} onClick={() => openDrawer(drawer === 'export' ? null : 'export')} title="Export DD1380 / MIST / status board / CSV / JSON">⇪ EXPORT</button>
<button className={'tb-btn' + (drawer === 'settings' ? ' on' : '') + (dueCount ? ' due' : '')} onClick={() => openDrawer(drawer === 'settings' ? null : 'settings')} title="Settings"><span className="spin">⚙</span> SETTINGS<span className="led" style={{ '--led': dueCount ? 'var(--crit)' : st.sound ? 'var(--op)' : 'var(--fg-3)' }}></span></button>
</div>
</div>
{hudOpen && <VoiceHUD listening={voiceOn} interim={interim} log={vlog} onClose={() => setHudOpen(false)} supported={voiceSupported} error={vErr} wake={st.voiceWake ? 'medic' : ''}/>}
{drawer === 'display' && <DisplayDrawer t={t} setTweak={setTweak} st={st} onClose={() => openDrawer(null)}/>}
{drawer === 'settings' && <SettingsDrawer st={st} setSt={setSt} onClose={() => openDrawer(null)} team={team} S={S} onResetTeam={resetTeam} onBlankTeam={blankTeam} profile={P} onResetSettings={() => setSt({ ...ODA_SETTINGS_DEFAULT })}/>}
{drawer === 'export' && <ExportDrawer st={st} S={S} team={team} teamAll={teamAll} mb={mb} profile={P} onClose={() => openDrawer(null)} onImport={(d) => setS({ ...d, settings: { ...ODA_SETTINGS_DEFAULT, ...(d.settings || {}) } })}/>}
{focus
? <div className={'focus-bar st-' + stat} data-screen-label="Focus bar">
<button className="fb-nav" onClick={() => step(-1)} title="Previous casualty (←)">◂</button>
<span className="fb-who"><b>{mb.role}</b> {mb.name}<span className="fb-pos">P{mb.pos} · {STATUS_LABEL[stat]}</span></span>
<span className="fb-dots">{stepList.map(x => <i key={x.id} className={'st-' + memberStatus(x) + (x.id === mb.id ? ' cur' : '')} onClick={() => { odaSnd('seg', st); select(x.id); }} title={x.role + ' ' + x.name}></i>)}</span>
<span className="fb-lvl">×{focus} {focus === 1 ? 'casualty' : focus === 2 ? 'injury map' : 'map + treatment'}</span>
{focus === 3 && <span className="fb-vitals">{[['HR', lastVital.hr, +lastVital.hr > 120 || +lastVital.hr < 50 ? 'crit' : +lastVital.hr > 100 ? 'warn' : ''], ['BP', lastVital.sbp ? `${lastVital.sbp}/${lastVital.dbp}` : null, +lastVital.sbp < 90 ? 'crit' : +lastVital.sbp < 100 ? 'warn' : ''], ['RR', lastVital.rr, +lastVital.rr > 24 || +lastVital.rr < 10 ? 'crit' : ''], ['SpO₂', lastVital.spo2, +lastVital.spo2 < 90 ? 'crit' : +lastVital.spo2 < 94 ? 'warn' : ''], ['AVPU', mb.avpu, mb.avpu && mb.avpu !== 'A' ? 'warn' : '']].map(([k, v, c]) => <span key={k} className={c}>{k} <b>{v || '—'}</b></span>)}<span>@ <b>{lastVital.time || '—'}</b></span></span>}
<span className="fb-hint">{stepIdx + 1}/{stepList.length} {stepList.length < team.length ? 'casualties' : 'team'} · ← → step · ↑ ↓ level · Esc exits</span>
{dueMap[mb.id] && <span className="fb-due">⚠ {dueMap[mb.id]}</span>}
<button className="fb-nav" onClick={() => step(1)} title="Next casualty (→)">▸</button>
</div>
: <TeamStrip team={team} selectedId={mb.id} onSelect={select} opts={st} dueMap={dueMap} profile={PA} platoons={allPlatoons} platoonId={S.platoonId} onPlatoon={(id) => { switchPlatoon(id); if (S.companyView) setCompanyView(false); }} company={P.companyView ? !!S.companyView : false} onCompany={P.companyView ? setCompanyView : null} onSelectIn={selectIn} dueAll={dueAll}/>}
{/* ===== CASUALTY HEADER ===== */}
{focus < 3 && <div className="roster" data-screen-label={'Casualty ' + mb.role}>
<div className="cell">
<div className="lbl">{mb.role} · {mb.title} · Last4</div>
<div className="val lg" style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<input value={mb.name} onChange={e => set('name')(e.target.value)} placeholder="LAST, F." style={{ width: '180px' }}/>
<span className="small">/</span>
<input value={mb.last4} onChange={e => set('last4')(e.target.value)} placeholder="0000" maxLength="4" style={{ width: '84px' }} className="last4-input"/>
</div>
<div className="findings-h"><span className="lbl" style={{ margin: 0 }}>Acute Findings</span><span className="findings-count">{mb.acuteFindings.length ? `${mb.acuteFindings.length} flagged` : 'tap to flag'}</span></div>
<div className="findings-chips">{ACUTE_FINDINGS.map(f => <div key={f} className={'fchip ' + (mb.acuteFindings.includes(f) ? 'active' : '')} onClick={() => toggleIn('acuteFindings', f)}>{f}</div>)}</div>
</div>
<div className="cell">
<div className="lbl">Readiness</div>
<div className="stat-row">{['auto', ...STATUS_ORDER].map(k => { const eff = k === 'auto' ? stat : k; return <button key={k} className={'st-' + eff + (mb.status === k ? ' active' : '')} onClick={() => set('status')(k)} title={k === 'auto' ? 'Derived from triage / evac / injuries' : STATUS_LABEL[k]}>{k === 'auto' ? 'Auto' : k}</button>; })}</div>
<div className="lbl" style={{ marginTop: '8px' }}>Ammo · Equipment · Position</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span className={'cyc st-' + mb.ammo} onClick={() => set('ammo')(cycle(mb.ammo))}><i></i>Ammo {mb.ammo}</span>
<span className={'cyc st-' + mb.equip} onClick={() => set('equip')(cycle(mb.equip))}><i></i>Eqp {mb.equip}</span>
<span className="val mono" style={{ fontSize: 12 }}>P<input type="number" min="1" max="99" value={mb.pos} onChange={e => set('pos')(Number(e.target.value) || 1)} style={{ width: 44 }}/></span>
</div>
<div className="lbl" style={{ marginTop: '8px' }}>Clocks</div>
<div className="clocks">
<div className="ck"><span className="k">Injury</span><span className="v"><input value={mb.injuryTime || ''} onChange={e => set('injuryTime')(e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="HHMM" maxLength="4"/><button className="mini-btn" onClick={() => set('injuryTime')(nowHHMM())}>Now</button></span><span className={'e' + (injAge != null && injAge >= 60 ? ' warn' : '')}>{injAge != null ? `T+${Math.floor(injAge / 60)}:${String(injAge % 60).padStart(2, '0')}` : '—'}</span></div>
<div className="ck"><span className="k">TQ on</span><span className="v">{firstTq ? `${firstTq.side} @${firstTq.time}` : '—'}</span><span className={'e' + (tqAge != null ? (tqAge >= 120 ? ' crit' : tqAge >= 90 ? ' warn' : '') : '')}>{tqAge != null ? `${Math.floor(tqAge / 60)}:${String(tqAge % 60).padStart(2, '0')}` : ''}</span></div>
<div className="ck"><span className="k">Last VS</span><span className="v">{lastVital.time || '—'}</span><span className={'e' + (vsAge != null && vsAge >= st.interval ? ' crit' : '')}>{vsAge != null ? `${vsAge} min` : ''}</span></div>
</div>
</div>
<div className="cell">
<div className="lbl">Sex · Weight</div>
<div className="val" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ display: 'inline-flex', gap: '4px' }}>{['M', 'F'].map(g => <button key={g} onClick={() => set('gender')(g)} style={{ background: mb.gender === g ? 'var(--op)' : 'transparent', border: '1px solid ' + (mb.gender === g ? 'var(--op)' : 'var(--line-2)'), color: mb.gender === g ? '#0c1410' : 'var(--fg-1)', fontFamily: 'var(--mono)', fontSize: '12px', width: '24px', height: '24px', borderRadius: '2px', cursor: 'pointer' }}>{g}</button>)}</span>
<span className="mono" style={{ fontSize: 13 }}><input type="number" value={mb.weight} onChange={e => set('weight')(Number(e.target.value) || 0)} style={{ width: '60px' }}/> kg</span>
</div>
<div className="lbl" style={{ marginTop: '6px' }}>Service · Unit</div>
<div className="val mono" style={{ fontSize: '12px' }}><input value={mb.service} onChange={e => set('service')(e.target.value)} style={{ width: '48px' }}/><span className="muted"> · </span><input value={mb.unit} onChange={e => set('unit')(e.target.value)} style={{ width: '90px' }}/></div>
<div className="lbl" style={{ marginTop: '8px' }}>Weight-based quick doses · {mb.weight || '—'} kg</div>
<div className="doses">
{[['Ketamine IV/IO', 0.3, 'mg', 'analgesia 0.3 mg/kg'], ['Ketamine IM/IN', 1, 'mg', '1 mg/kg'], ['TXA', null, '2 g IV/IO', 'slow push · ≤3 h'], ['Ertapenem', null, '1 g IV/IM', 'q24h'], ['Ca gluconate', null, '1 g IV', 'after 1st blood unit'], ['Fentanyl OTFC', null, '800 mcg', 'not in shock']].map(([n, perKg, u, note]) => <div key={n} className="dose" title={note}><span className="n">{n}</span><b>{perKg ? (mb.weight ? `${Math.round(perKg * mb.weight)} ${u}` : '—') : u}</b></div>)}
</div>
</div>
<div className="cell">
<div className="lbl">Blood Type</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '2px' }}><span className="blood-chip" title="Set in the Blood tab" style={{ cursor: 'pointer' }} onClick={() => setTab('blood')}>{mb.bloodType}</span>{(!mb.bloodType || mb.bloodType === '—') && <span className="muted mono tiny">set in Blood tab →</span>}</div>
<div className="lbl" style={{ marginTop: '6px' }}>Allergies</div>
<div className="val mono" style={{ fontSize: '12px', color: mb.allergies === 'NKDA' ? 'var(--op)' : 'var(--amber)' }}><input value={mb.allergies} onChange={e => set('allergies')(e.target.value)}/></div>
<div className="lbl" style={{ marginTop: '8px' }}>Donors on hand · {donors.length} compatible</div>
<div className="donors">{donors.length ? donors.slice(0, 6).map(d => <span key={d.id} className="donor" onClick={() => select(d.id)} title={`${d.role} ${d.name} · ${d.bloodType} · up`}><b>{d.bloodType}</b>{d.name.split(',')[0]}</span>) : <span className="muted mono tiny">{mb.bloodType && mb.bloodType !== '—' ? 'no uninjured compatible donor in this unit' : 'set blood type first'}</span>}{donors.length > 6 && <span className="donor more" onClick={() => setTab('blood')}>+{donors.length - 6}</span>}</div>
</div>
<div className="cell">
<div className="lbl">EVAC Priority</div>
<div className="evac">{['urgent', 'priority', 'routine'].map(k => <button key={k} className={mb.evac === k ? 'active ' + k : ''} onClick={() => set('evac')(mb.evac === k ? '' : k)}>{k}</button>)}</div>
<div className="lbl" style={{ marginTop: '8px' }}>Triage</div>
<div className="triage">{[{ k: 'Immediate', c: 'var(--crit)' }, { k: 'Delayed', c: 'var(--amber)' }, { k: 'Minimal', c: 'var(--op)' }, { k: 'Expectant', c: '#555' }].map(opt => { const a = mb.triage === opt.k.toLowerCase(); return <button key={opt.k} className={'tri-btn ' + (a ? 'active' : '')} style={{ '--tri-c': opt.c, color: a ? '#fff' : opt.c, background: a ? opt.c : 'transparent', borderColor: opt.c }} onClick={() => set('triage')(a ? '' : opt.k.toLowerCase())} title={opt.k}>{opt.k.slice(0, 3)}</button>; })}</div>
<div className="lbl" style={{ marginTop: '8px' }}>Evac snapshot</div>
<div className="snap">
<div className="seg" style={{ display: 'flex', gap: 4 }}>{[['litter', 'Litter'], ['amb', 'Ambulatory']].map(([k, l]) => <button key={k} className={'mini-btn' + ((mb.carry || (autoLitter ? 'litter' : 'amb')) === k ? ' on' : '')} onClick={() => set('carry')(k)} style={{ flex: 1 }}>{l}</button>)}</div>
<div className="snap-row"><span>9-Line</span><b>{mb.evac ? ({ urgent: 'A · Urgent', priority: 'C · Priority', routine: 'D · Routine' })[mb.evac] : '— not flagged'}</b></div>
<div className="snap-row"><span>Card</span><b>{mb.interventions.length} tx · {mb.medsGiven.length} meds · {mb.fluidsGiven.length} fluids · {mb.vitals.length} VS</b></div>
<div className="snap-row"><span>DD 1380</span><b className={mb.interventions.includes('D:DD 1380 started') ? 'ok' : 'warn'} style={{ cursor: 'pointer' }} onClick={() => toggleIn('interventions', 'D:DD 1380 started')}>{mb.interventions.includes('D:DD 1380 started') ? '✓ started' : '□ not started'}</b></div>
</div>
</div>
</div>}
{/* ===== MAIN ===== */}
<div className="main">
<div className="col col-body"><div className="scrollable">
<div className="panel">
<div className="panel-h"><span className="tick"></span><b>INJURY MAP</b><span className="right">{mb.injuries.length} marks · {activeTQs} TQ{tbsa > 0 ? ` · ${tbsa.toFixed(1)}% TBSA` : ''}</span></div>
<BodyDiagram key={mb.id} injuries={mb.injuries} setInjuries={set('injuries')} tourniquets={mb.tourniquets} setTourniquets={set('tourniquets')} burnedRegions={mb.burnedRegions || []} setBurnedRegions={set('burnedRegions')} weightKg={mb.weight}/>
</div>
<div className="panel">
<div className="panel-h"><span className="tick"></span><b>MECHANISM OF INJURY</b></div>
<div className="panel-body"><div className="chips">{MECHANISMS.map(x => <div key={x} className={'chip ' + (mb.mechanism.includes(x) ? 'active' : '')} onClick={() => toggleIn('mechanism', x)}>{x}</div>)}</div></div>
</div>
</div></div>
<div className="col col-vitals"><div className="scrollable">
<div className="panel">
<div className="panel-h"><span className="tick"></span><b>SIGNS &amp; SYMPTOMS · LIVE</b><span className="right">{mb.role} {mb.name} · latest: {lastVital.time || '—'}</span></div>
<VitalsPanel key={mb.id + (ecgOn ? 'a' : 'm')} vitals={mb.vitals} setVitals={set('vitals')} draft={mb.draftVitals} setDraft={set('draftVitals')} avpu={mb.avpu} setAVPU={set('avpu')} showSparklines={t.showSparklines}/>
</div>
<div className="panel"><div className="panel-h"><span className="tick"></span><b>TQ &amp; MED · TIMELINE</b></div><Timeline state={mb} setState={setMember}/></div>
<div className="panel"><div className="panel-h"><span className="tick"></span><b>FIELD NOTES · MIST</b><span className="right">auto-fills as data is entered</span></div><MistPanel state={mb} setNotes={set('notes')} notes={mb.notes}/></div>
</div></div>
<div className="col col-tabs">
<div className="tabs">{[['march', 'MARCH'], ['burn', 'Burn'], ['meds', 'Meds'], ['drip', 'Drip'], ['blood', 'Blood'], ['nine', '9-Line'], ['ref', 'Ref']].map(([k, l]) => <button key={k} className={tab === k ? 'active' : ''} onClick={() => setTab(k)}>{l}</button>)}</div>
{tab === 'march' && <MarchTab key={mb.id} mb={mb} upd={upd} now={now}/>}
{tab === 'burn'  && <BurnTab key={mb.id} mb={mb} upd={upd} now={now}/>}
{tab === 'meds'  && <MedsTab medsGiven={mb.medsGiven} setMedsGiven={set('medsGiven')} weight={mb.weight}/>}
{tab === 'drip'  && <DripTab key={mb.id} mb={mb} upd={upd} now={now}/>}
{tab === 'blood' && <div className="scrollable"><TeamBloodBank team={team} mb={mb} onSelect={select}/><BloodTab bloodType={mb.bloodType} setBloodType={set('bloodType')} fluidsGiven={mb.fluidsGiven} setFluidsGiven={set('fluidsGiven')}/></div>}
{tab === 'nine'  && <NineLine team={team} nine={S.nine || {}} setNine={setTop('nine')}/>}
{tab === 'ref'   && <RefTab/>}
</div>
</div>
<div className="statusbar">
<div className="seg"><span>●</span><b style={{ color: 'var(--op)' }}>SECURE</b></div>
<div className="seg">{P.unitShort} <b style={{ color: cas ? 'var(--crit)' : 'var(--op)' }}>{team.length - cas}/{team.length} UP</b></div>{allPlatoons.length > 1 && <div className="seg">{P.teamLabel || 'TEAM'} <b style={{ color: teamAll.length - teamAll.filter(x => memberStatus(x) === 'green').length ? 'var(--amber)' : 'var(--op)' }}>{teamAll.filter(x => memberStatus(x) === 'green').length}/{teamAll.length} UP</b></div>}
<div className="seg">SEL <b>{mb.role} {mb.name}</b></div>
<div className="seg">EVAC <b style={{ color: mb.evac === 'urgent' ? 'var(--crit)' : mb.evac === 'priority' ? 'var(--amber)' : 'var(--op)' }}>{(mb.evac || '—').toUpperCase()}</b></div>
<div className="seg">TQ <b>{activeTQs}/4</b></div>
<div className="seg">VITALS <b>{mb.vitals.length} rec</b></div>
{dueCount > 0 && <div className="seg"><b style={{ color: 'var(--crit)', animation: 'pulse 1.2s ease-in-out infinite' }}>⚠ {dueCount} DUE</b></div>}
<div style={{ marginLeft: 'auto' }}></div>
<div className="seg">{(t.timeMode === 'local' || t.timeMode === 'both') && <><b>{localTime}</b> LOC</>}{t.timeMode === 'both' && ' · '}{(t.timeMode === 'zulu' || t.timeMode === 'both') && <b>{zuluTime}</b>}</div>
<div className="seg"><button onClick={clearMember} style={{ background: 'transparent', border: 'none', color: 'var(--fg-3)', fontFamily: 'var(--mono)', fontSize: '10px', letterSpacing: '0.15em', cursor: 'pointer', textTransform: 'uppercase' }}>⨯ CLEAR {mb.role}</button></div>
<div className="seg"><button onClick={resetTeam} style={{ background: 'transparent', border: 'none', color: 'var(--fg-3)', fontFamily: 'var(--mono)', fontSize: '10px', letterSpacing: '0.15em', cursor: 'pointer', textTransform: 'uppercase' }}>↺ RESET TEAM</button></div>
</div>
<TweaksPanel title="Tweaks">
<TweakSection label="Theme" />
<TweakSelect label="Mode" value={t.theme} options={[{ value: 'tactical', label: 'Tactical (night)' }, { value: 'day', label: 'Day mode' }, { value: 'paper', label: 'DD1380 Paper' }]} onChange={(v) => setTweak('theme', v)} />
<TweakSelect label="Accent" value={t.accent} options={[{ value: 'op', label: 'Operational Green' }, { value: 'amber', label: 'Caution Amber' }, { value: 'crit', label: 'Critical Red' }, { value: 'info', label: 'Tactical Blue' }]} onChange={(v) => setTweak('accent', v)} />
<TweakSection label="Layout" />
<TweakRadio label="Density" value={t.density} options={['compact', 'regular', 'roomy']} onChange={(v) => setTweak('density', v)} />
<TweakToggle label="Grid backdrop" value={t.showGrid} onChange={(v) => setTweak('showGrid', v)} />
<TweakToggle label="Vital sparklines" value={t.showSparklines} onChange={(v) => setTweak('showSparklines', v)} />
<TweakSection label="Time" />
<TweakRadio label="Display" value={t.timeMode} options={[{ value: 'local', label: 'Local' }, { value: 'zulu', label: 'Zulu' }, { value: 'both', label: 'Both' }]} onChange={(v) => setTweak('timeMode', v)} />
</TweaksPanel>
</div>
);
}
function Timeline({ state, setState }) {
const events = [];
state.medsGiven.forEach(m => events.push({
t: m.time, k: 'med', label: m.name, sub: m.dose,
onClear: () => setState(p => ({ ...p, medsGiven: p.medsGiven.filter(x => x.id !== m.id) }))
}));
state.fluidsGiven.forEach(m => events.push({
t: m.time, k: 'fluid', label: m.type, sub: m.vol ? m.vol + ' mL' : '',
onClear: () => setState(p => ({ ...p, fluidsGiven: p.fluidsGiven.filter(x => x.id !== m.id) }))
}));
state.vitals.forEach((v, idx) => events.push({
t: v.time, k: 'vital',
label: `HR ${v.hr} · BP ${v.sbp}/${v.dbp}`,
sub: `RR ${v.rr} SpO₂ ${v.spo2}`,
onClear: () => setState(p => ({ ...p, vitals: p.vitals.filter((_, i) => i !== idx) }))
}));
Object.entries(state.tourniquets).forEach(([k, tq]) => {
if (tq.active && tq.time) {
const side = { rArm:'R ARM', lArm:'L ARM', rLeg:'R LEG', lLeg:'L LEG' }[k];
events.push({
t: tq.time, k: 'tq', label: `TQ ${side}`, sub: [tq.type, tq.position].filter(Boolean).join(' · ') || 'applied',
onClear: () => setState(p => ({
...p,
tourniquets: { ...p.tourniquets, [k]: { active: false, type: '', time: '', position: '' } }
}))
});
}
});
events.sort((a, b) => (a.t || '').localeCompare(b.t || ''));
if (events.length === 0) {
return <div style={{padding:'14px', fontFamily:'var(--mono)', fontSize:'11px', color:'var(--fg-3)'}}>No events yet.</div>;
}
const times = events.map(e => parseTime(e.t)).filter(n => !isNaN(n));
const t0 = Math.min(...times);
const t1 = Math.max(...times);
const span = Math.max(1, t1 - t0);
return (
<div style={{padding:'4px 14px 14px'}}>
<div style={{position:'relative', height: events.length * 26 + 30}}>
{/* axis */}
<div style={{position:'absolute', left:'72px', right:'24px', top:14, height:1, background:'var(--line-2)'}}></div>
{/* tick labels */}
<div style={{position:'absolute', left:'72px', right:'24px', top:0, display:'flex', justifyContent:'space-between', fontFamily:'var(--mono)', fontSize:'9px', color:'var(--fg-3)'}}>
<span>{events[0]?.t || ''}</span>
<span>{events[events.length-1]?.t || ''}</span>
</div>
{events.map((e, i) => {
const tx = parseTime(e.t);
const pct = isNaN(tx) ? 0 : ((tx - t0) / span) * 100;
const color = e.k === 'tq' ? 'var(--op)'
: e.k === 'med' ? 'var(--amber)'
: e.k === 'fluid' ? 'var(--info)'
: 'var(--fg-2)';
return (
<div key={i} className="tl-row" style={{
position:'absolute', top: 26 + i*22, left:0, right:0,
display:'grid', gridTemplateColumns:'56px 1fr 22px',
alignItems:'center', gap:'8px',
fontFamily:'var(--mono)', fontSize:'11px'
}}>
<span style={{color:'var(--fg-2)', textAlign:'right'}}>{e.t || '----'}</span>
<span style={{position:'relative', display:'block', height:'14px'}}>
<span style={{
position:'absolute', top:'3px',
width:8, height:8, background: color, borderRadius: e.k==='tq'?'1px':'50%',
left: `calc(${pct}% - 4px)`,
boxShadow:`0 0 6px ${color}`
}}></span>
<span style={{position:'absolute', left:0, top:'-2px', color:'var(--fg-0)', whiteSpace:'nowrap'}}>
{e.label} <span style={{color:'var(--fg-2)'}}>· {e.sub}</span>
</span>
</span>
<button className="tl-clear" onClick={e.onClear} title="Clear this event">×</button>
</div>
);
})}
</div>
</div>
);
}
function parseTime(t) {
if (!t) return NaN;
const s = String(t).replace(':','');
const h = parseInt(s.slice(0,2));
const m = parseInt(s.slice(2,4));
if (isNaN(h) || isNaN(m)) return NaN;
return h * 60 + m;
}
function injurySite(i) {
const { x, y, view } = i;
const patientR = view === 'front' ? x < 100 : x >= 100;
const side = patientR ? 'R' : 'L';
const post = view === 'back' ? ' (post)' : '';
const midline = x >= 88 && x <= 112;
if (y < 70) return `${midline ? '' : side + ' '}head/neck${post}`;
if (y < 245 && (x < 55 || x > 145)) return `${side} ${y < 150 ? 'upper' : 'lower'} arm${post}`;
if (y < 158) return view === 'back' ? `${midline ? '' : side + ' '}upper back` : `${midline ? 'central ' : side + ' '}chest`;
if (y < 232) return view === 'back' ? `${midline ? '' : side + ' '}lower back` : `${midline ? '' : side + ' '}${x < 70 || x > 130 ? 'flank' : 'abdomen'}`;
if (y < 262) return view === 'back' ? `${side} buttock` : (midline ? 'pelvis/perineum' : `${side} groin`);
if (y < 345) return `${side} thigh${post}`;
if (y < 420) return `${side} lower leg${post}`;
return `${side} foot${post}`;
}
function buildMIST(state) {
const M = state.mechanism && state.mechanism.length
? state.mechanism.join(' · ')
: '—';
const counts = { gsw: 0, burn: 0, frac: 0, shrap: 0 };
(state.injuries || []).forEach(i => { counts[i.type] = (counts[i.type] || 0) + 1; });
const front = (state.injuries || []).filter(i => i.view === 'front').length;
const back  = (state.injuries || []).filter(i => i.view === 'back').length;
const injParts = [];
const sites = (state.injuries || []).filter(i => i.type !== 'burn' && i.x != null).map(i => `${({gsw:'GSW',frac:'Frac',shrap:'Shrap'})[i.type] || i.type} ${injurySite(i)}`);
if (sites.length) injParts.push(...sites);
if (counts.burn || (state.burnedRegions && state.burnedRegions.length)) {
const burnTbsa = (typeof estimateBurnTBSA === 'function') ? estimateBurnTBSA(state.injuries, state.burnedRegions) : 0;
if (burnTbsa > 0) injParts.push(`Burn ~${burnTbsa.toFixed(1)}% TBSA`);
else if (counts.burn) injParts.push(`${counts.burn}× Burn`);
}
if (front || back) injParts.push(`(ant ${front}/post ${back})`);
const tqActive = Object.entries(state.tourniquets || {})
.filter(([_,t]) => t.active)
.map(([k,t]) => {
const side = { rArm:'R arm', lArm:'L arm', rLeg:'R leg', lLeg:'L leg' }[k];
return `TQ ${side}${t.type ? ' '+t.type : ''}${t.position ? ' ('+t.position+')' : ''}${t.time ? ' @'+t.time : ''}`;
});
if (tqActive.length) injParts.push(...tqActive);
if (state.acuteFindings && state.acuteFindings.length) {
injParts.unshift(state.acuteFindings.join(' · '));
}
const I = injParts.length ? injParts.join(', ') : '—';
const draft = state.draftVitals || {};
const draftHasData = !!(draft.hr || draft.sbp || draft.dbp || draft.rr || draft.spo2 || draft.pain);
const v = draftHasData
? { ...draft, time: '' }
: (state.vitals || [])[state.vitals.length - 1];
let S = '—';
if (v) {
const bits = [];
if (v.hr)  bits.push(`HR ${v.hr}`);
if (v.sbp || v.dbp) bits.push(`BP ${v.sbp||'?'}/${v.dbp||'?'}`);
if (v.rr)  bits.push(`RR ${v.rr}`);
if (v.spo2)bits.push(`SpO₂ ${v.spo2}`);
if (v.pain)bits.push(`Pain ${v.pain}/10`);
if (state.avpu) bits.push(`AVPU ${state.avpu}`);
S = (v.time ? `@${v.time} ` : '') + bits.join(' · ');
} else if (state.avpu) {
S = `AVPU ${state.avpu}`;
}
const tx = [];
(state.interventions || []).forEach(s => {
const [, label] = s.split(':');
if (label) tx.push(label);
});
(state.medsGiven || []).forEach(m => {
tx.push(`${m.name} ${m.dose}${m.time ? ' @'+m.time : ''}`);
});
(state.fluidsGiven || []).forEach(f => {
tx.push(`${f.type}${f.vol ? ' '+f.vol+'mL' : ''}${f.time ? ' @'+f.time : ''}`);
});
const T = tx.length ? tx.join('; ') : '—';
return { M, I, S, T };
}
function MistPanel({ state, notes, setNotes }) {
const mist = buildMIST(state);
const fullText = `M: ${mist.M}\nI: ${mist.I}\nS: ${mist.S}\nT: ${mist.T}`;
const copyToNotes = () => {
const sep = notes && notes.trim() ? '\n\n' : '';
setNotes((notes || '') + sep + `--- MIST @ ${nowHHMM()} ---\n` + fullText);
};
return (
<div style={{padding:'8px 14px 14px'}}>
<div className="mist-grid">
{[
['M', 'Mechanism',  mist.M],
['I', 'Injuries',   mist.I],
['S', 'Signs',      mist.S],
['T', 'Treatments', mist.T],
].map(([k, label, val]) => (
<div className="mist-row" key={k}>
<span className="mist-k"><b>{k}</b><span className="mist-lbl">{label}</span></span>
<span className="mist-v">{val}</span>
</div>
))}
</div>
<div style={{display:'flex', gap:'6px', marginTop:'10px', alignItems:'center'}}>
<button className="mist-btn" onClick={copyToNotes} title="Snapshot current MIST to narrative notes">
⇣ APPEND TO NOTES
</button>
<span style={{flex:1}}></span>
<span className="muted mono tiny" style={{letterSpacing:'0.1em'}}>NARRATIVE</span>
</div>
<textarea
className="notes"
value={notes}
onChange={e=>setNotes(e.target.value)}
placeholder="Free-text narrative. Click APPEND TO NOTES to stamp current MIST + time."
style={{marginTop:'4px'}}
/>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);