perf(charting): add Cmd+S instant sign hotkey, debounced localStorage draft auto-save, and rapid clinical macro chips
This commit is contained in:
@@ -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 & Locked
|
||||
</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.
|
||||
|
||||
Reference in New Issue
Block a user