perf(charting): add Cmd+S instant sign hotkey, debounced localStorage draft auto-save, and rapid clinical macro chips

This commit is contained in:
2026-09-07 10:59:58 -07:00
parent b8b03a2b3c
commit caf50a1b62
+185 -7
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
SoapNote,
Patient,
@@ -88,6 +88,62 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
const [vasScore, setVasScore] = useState<number>(initialSoapNote?.vasScore || 3);
const [isSigned, setIsSigned] = useState<boolean>(initialSoapNote?.status === 'signed');
const [signedTimestamp, setSignedTimestamp] = useState<string | undefined>(initialSoapNote?.signedAt);
const [lastAutoSaved, setLastAutoSaved] = useState<string | null>(null);
// Restore draft from local storage if note is un-signed
useEffect(() => {
if (!initialSoapNote && typeof window !== 'undefined') {
try {
const key = `mediusa_draft_soap_${patient.id}`;
const raw = localStorage.getItem(key);
if (raw) {
const draft = JSON.parse(raw);
if (draft.subjective) setSubjective(draft.subjective);
if (draft.objective) setObjective(draft.objective);
if (draft.assessment) setAssessment(draft.assessment);
if (draft.plan) setPlan(draft.plan);
if (draft.vasScore !== undefined) setVasScore(draft.vasScore);
if (draft.adjustments && Array.isArray(draft.adjustments)) setAdjustments(draft.adjustments);
setLastAutoSaved(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
}
} catch {}
}
}, [patient.id, initialSoapNote]);
// Debounced auto-save draft to local storage
useEffect(() => {
if (isSigned) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined') {
try {
const key = `mediusa_draft_soap_${patient.id}`;
localStorage.setItem(
key,
JSON.stringify({
subjective,
objective,
assessment,
plan,
vasScore,
adjustments,
timestamp: Date.now(),
})
);
setLastAutoSaved(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
} catch {}
}
}, 800);
return () => clearTimeout(timer);
}, [patient.id, isSigned, subjective, objective, assessment, plan, vasScore, adjustments]);
// Fast macro appender helper
const handleAppendMacro = (setter: React.Dispatch<React.SetStateAction<string>>, phrase: string) => {
clinicalAudio.playClick();
setter((prev) => {
const trimmed = prev.trim();
return trimmed ? `${trimmed} ${phrase}` : phrase;
});
};
const handleApplyExtractedSoap = (data: {
vasScore: number;
@@ -154,12 +210,18 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
setPlan('1. Diversified adjustment delivered to indicated segments.\n2. Manual myofascial release 10 minutes.\n3. Continue active home rehabilitation.');
};
const handleSignChart = () => {
const handleSignChart = useCallback(() => {
clinicalAudio.playSuccess();
const now = new Date().toISOString();
setIsSigned(true);
setSignedTimestamp(now);
if (typeof window !== 'undefined') {
try {
localStorage.removeItem(`mediusa_draft_soap_${patient.id}`);
} catch {}
}
const savedNote: SoapNote = {
id: initialSoapNote?.id || `soap-${Date.now()}`,
tenantId: patient.tenantId,
@@ -183,7 +245,35 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
};
onSaveSoapNote(savedNote);
};
}, [
initialSoapNote,
patient,
provider,
discipline,
vasScore,
subjective,
objective,
assessment,
plan,
adjustments,
selectedIcd10,
selectedCpt,
onSaveSoapNote,
]);
// Global Cmd+S / Ctrl+S hotkey to instant-sign
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
if (!isSigned) {
handleSignChart();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSigned, handleSignChart]);
const handleCreateSuperbillClick = () => {
clinicalAudio.playSuccess();
@@ -244,9 +334,16 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
<ShieldCheck className="w-3.5 h-3.5" /> Signed &amp; Locked
</span>
) : (
<span className="text-xs px-2.5 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 font-semibold">
In Progress (Draft)
</span>
<div className="flex items-center gap-2">
<span className="text-xs px-2.5 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 font-semibold">
In Progress (Draft)
</span>
{lastAutoSaved && (
<span className="text-[10px] text-slate-500 font-mono hidden sm:inline">
Auto-saved {lastAutoSaved}
</span>
)}
</div>
)}
</div>
@@ -338,6 +435,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
type="button"
onClick={handleSignChart}
disabled={isSigned}
title={isSigned ? 'Chart Digitally Sealed' : 'Sign & Complete Note (Cmd+S / Ctrl+S)'}
className={`px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition shadow-xs ${
isSigned
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 cursor-default'
@@ -345,7 +443,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
}`}
>
<CheckCircle className="w-4 h-4" />
{isSigned ? 'Signed & Locked' : 'Sign & Complete Note'}
{isSigned ? 'Signed & Locked' : 'Sign & Complete (⌘S)'}
</button>
<button
@@ -422,6 +520,26 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
onChange={(e) => setSubjective(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-lg p-3 text-xs text-slate-800 focus:bg-white focus:outline-none focus:border-sky-500 resize-none"
/>
{!isSigned && (
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<span className="text-[10px] uppercase font-bold text-slate-400">Quick:</span>
{[
'Morning stiffness <15min',
'Pain rated 4/10 with sitting',
'50% relief post-adj',
'No radiating numbness',
].map((chip) => (
<button
key={chip}
type="button"
onClick={() => handleAppendMacro(setSubjective, chip)}
className="text-[10px] px-2 py-0.5 bg-slate-100 hover:bg-sky-50 text-slate-600 hover:text-sky-800 border border-slate-200 rounded-md transition"
>
+ {chip}
</button>
))}
</div>
)}
</div>
<div className="text-[11px] text-slate-400 mt-2">
Auto-populated from Intake and Ambient Scribe.
@@ -449,6 +567,26 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
onChange={(e) => setObjective(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-lg p-3 text-xs text-slate-800 focus:bg-white focus:outline-none focus:border-sky-500 resize-none"
/>
{!isSigned && (
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<span className="text-[10px] uppercase font-bold text-slate-400">Quick:</span>
{[
'Cervical active ROM restricted',
'Lumbar paraspinal hypertonicity',
'Negative Kemp test bilaterally',
'Negative Straight Leg Raise',
].map((chip) => (
<button
key={chip}
type="button"
onClick={() => handleAppendMacro(setObjective, chip)}
className="text-[10px] px-2 py-0.5 bg-slate-100 hover:bg-sky-50 text-slate-600 hover:text-sky-800 border border-slate-200 rounded-md transition"
>
+ {chip}
</button>
))}
</div>
)}
</div>
<div className="text-[11px] text-slate-400 mt-2">
Vertebrae selected in Spine Map sync automatically.
@@ -473,6 +611,26 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
onChange={(e) => setAssessment(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-lg p-3 text-xs text-slate-800 focus:bg-white focus:outline-none focus:border-sky-500 resize-none"
/>
{!isSigned && (
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<span className="text-[10px] uppercase font-bold text-slate-400">Quick:</span>
{[
'Meeting care plan milestones',
'Subluxation complex stable',
'Antalgic lean resolved',
'Spinal biomechanics progressing',
].map((chip) => (
<button
key={chip}
type="button"
onClick={() => handleAppendMacro(setAssessment, chip)}
className="text-[10px] px-2 py-0.5 bg-slate-100 hover:bg-sky-50 text-slate-600 hover:text-sky-800 border border-slate-200 rounded-md transition"
>
+ {chip}
</button>
))}
</div>
)}
</div>
<div className="text-[11px] text-slate-400 mt-2">
Mapped to ICD-10 diagnostic codes below.
@@ -497,6 +655,26 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
onChange={(e) => setPlan(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-lg p-3 text-xs text-slate-800 focus:bg-white focus:outline-none focus:border-sky-500 resize-none"
/>
{!isSigned && (
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<span className="text-[10px] uppercase font-bold text-slate-400">Quick:</span>
{[
'Diversified adjustments delivered',
'Home postural stabilization exercises',
'Cryotherapy 15min post-treatment',
'Return in 3 days for visit 4/12',
].map((chip) => (
<button
key={chip}
type="button"
onClick={() => handleAppendMacro(setPlan, chip)}
className="text-[10px] px-2 py-0.5 bg-slate-100 hover:bg-sky-50 text-slate-600 hover:text-sky-800 border border-slate-200 rounded-md transition"
>
+ {chip}
</button>
))}
</div>
)}
</div>
<div className="text-[11px] text-slate-400 mt-2">
Populates CPT procedure line items on Superbill.