Recovered from git history (commit be14578) — the vanilla recording UI
was deleted during the "minimum-viable" note ports without being
re-implemented. Every note page now gets the full vanilla behavior
back:
Recording (Encounter, Dictation, SOAP, Sick Visit, Hospital Course):
• AudioRecorder — MediaRecorder mono/16kHz/EC+NS/opus 32kbps
• Pause / Resume (native where supported, restart-on-same-stream
fallback for Safari)
• Live preview via Web Speech API (opt-in in Settings)
• On stop → upload to /api/transcribe; fall back to live preview
if server unavailable or blob > 24 MB
• Failed uploads → /api/audio-backups (IndexedDB fallback)
Save / Load / New-patient toolbar (all 7 note pages):
• sessionStorage keys _savedEncId_<type> + _idempKey_<type>
survive page refresh + sign-out within the same tab
• Optimistic locking via expected_version (409 → "Someone else
edited this encounter")
• Load popover lists saved encounters of matching type only
• Draft #N chip shows current session-bound row
Well Visit and Chart Review: toolbar only — vanilla had no recorder
on those tabs (paste-based workflows).
New components:
client/src/components/Recorder.tsx
client/src/components/EncounterToolbar.tsx
New libraries:
client/src/lib/recorder.ts — AudioRecorder class
client/src/lib/transcribe.ts — /api/transcribe + audio backup
client/src/lib/web-speech.ts — webkit speech preview + dedupe
client/src/lib/encounter-persistence.ts — save/load/version tracking
159 lines
6.3 KiB
TypeScript
159 lines
6.3 KiB
TypeScript
// ============================================================
|
|
// Encounter Toolbar — Save / New Patient / Load + label input.
|
|
// Mirrors the vanilla saveFromTab / clearTab flow from
|
|
// public/js/encounters.js. Each note page gets one of these so
|
|
// transcripts + generated notes persist across sign-outs.
|
|
// ============================================================
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import {
|
|
saveEncounter, loadEncounter, listSavedEncounters,
|
|
getSavedEncId, clearTabState,
|
|
type EncType, type SavedEncounterListEntry, type LoadedEncounter,
|
|
} from '@/lib/encounter-persistence';
|
|
|
|
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
|
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
|
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
|
|
|
|
interface Props {
|
|
type: EncType;
|
|
label: string;
|
|
setLabel: (s: string) => void;
|
|
transcript: string;
|
|
generatedNote: string;
|
|
partialData?: unknown;
|
|
onLoad: (enc: LoadedEncounter) => void;
|
|
onClear: () => void;
|
|
}
|
|
|
|
export default function EncounterToolbar({
|
|
type, label, setLabel, transcript, generatedNote, partialData, onLoad, onClear,
|
|
}: Props) {
|
|
const [msg, setMsg] = useState<{ kind: 'ok' | 'err' | 'info'; text: string } | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
const [saved, setSaved] = useState<SavedEncounterListEntry[]>([]);
|
|
const [loadingList, setLoadingList] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [savedId, setSavedId] = useState<number | null>(getSavedEncId(type));
|
|
|
|
// Sync the savedId chip whenever sessionStorage changes (e.g. after Load).
|
|
useEffect(() => {
|
|
const v = getSavedEncId(type);
|
|
setSavedId(v);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [transcript, generatedNote]);
|
|
|
|
async function save() {
|
|
if (!label.trim()) { setMsg({ kind: 'err', text: 'Enter a patient label first' }); return; }
|
|
setMsg(null); setSaving(true);
|
|
try {
|
|
const r = await saveEncounter({ type, label, transcript, generatedNote, partialData });
|
|
setSavedId(r.id);
|
|
setMsg({ kind: 'ok', text: 'Saved (' + label + ')' });
|
|
} catch (e) {
|
|
setMsg({ kind: 'err', text: (e as Error).message });
|
|
} finally { setSaving(false); }
|
|
}
|
|
|
|
function newPatient() {
|
|
clearTabState(type);
|
|
setSavedId(null);
|
|
setLabel('');
|
|
onClear();
|
|
setMsg({ kind: 'info', text: 'New patient — fields cleared.' });
|
|
}
|
|
|
|
async function openLoad() {
|
|
if (popoverOpen) { setPopoverOpen(false); return; }
|
|
setPopoverOpen(true);
|
|
setLoadingList(true);
|
|
try {
|
|
const list = await listSavedEncounters();
|
|
setSaved(list.filter((e) => e.enc_type === type));
|
|
} finally { setLoadingList(false); }
|
|
}
|
|
async function pick(id: number) {
|
|
try {
|
|
const enc = await loadEncounter(id);
|
|
setSavedId(id);
|
|
onLoad(enc);
|
|
setPopoverOpen(false);
|
|
setMsg({ kind: 'ok', text: 'Loaded ' + (enc.label || '') });
|
|
} catch (e) {
|
|
setMsg({ kind: 'err', text: (e as Error).message });
|
|
}
|
|
}
|
|
|
|
const filtered = search
|
|
? saved.filter((e) => (e.label || '').toLowerCase().includes(search.toLowerCase()))
|
|
: saved;
|
|
|
|
return (
|
|
<div className="space-y-2" data-testid={'enc-toolbar-' + type}>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<input
|
|
className={input + ' flex-1 min-w-[180px] max-w-md'}
|
|
placeholder="Patient label (e.g. JD-2026-04-24)"
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
data-testid="enc-label"
|
|
/>
|
|
<button type="button" onClick={save} disabled={saving} className={btnPrimary} data-testid="enc-save">
|
|
💾 {saving ? 'Saving…' : (savedId != null ? 'Save (update)' : 'Save')}
|
|
</button>
|
|
<div className="relative">
|
|
<button type="button" onClick={openLoad} className={btn} data-testid="enc-load-toggle">
|
|
📂 Load
|
|
</button>
|
|
{popoverOpen && (
|
|
<div className="absolute z-30 mt-1 right-0 w-80 max-h-96 overflow-auto rounded-md border border-border bg-card shadow-lg">
|
|
<div className="sticky top-0 bg-card p-2 border-b border-border">
|
|
<input
|
|
type="search"
|
|
placeholder="Filter saved encounters…"
|
|
className={input + ' w-full text-xs'}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
autoFocus
|
|
data-testid="enc-load-search"
|
|
/>
|
|
</div>
|
|
{loadingList && <div className="p-3 text-xs text-muted-foreground">Loading…</div>}
|
|
{!loadingList && filtered.length === 0 && (
|
|
<div className="p-3 text-xs text-muted-foreground italic">No saved {type} encounters.</div>
|
|
)}
|
|
{filtered.map((e) => (
|
|
<button
|
|
key={e.id}
|
|
type="button"
|
|
onClick={() => pick(e.id)}
|
|
className="w-full text-left px-3 py-2 hover:bg-muted border-b border-border last:border-0"
|
|
data-testid={'enc-load-pick-' + e.id}
|
|
>
|
|
<div className="text-sm font-medium truncate">{e.label}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Updated {new Date(e.updated_at).toLocaleString()} · expires {new Date(e.expires_at).toLocaleDateString()}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button type="button" onClick={newPatient} className={btn} data-testid="enc-new">
|
|
🆕 New patient
|
|
</button>
|
|
{savedId != null && (
|
|
<span className="text-xs text-muted-foreground">Draft #{savedId}</span>
|
|
)}
|
|
</div>
|
|
{msg && (
|
|
<div className={
|
|
'text-sm ' +
|
|
(msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground')
|
|
}>{msg.text}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|