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';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
SoapNote,
|
SoapNote,
|
||||||
Patient,
|
Patient,
|
||||||
@@ -88,6 +88,62 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
const [vasScore, setVasScore] = useState<number>(initialSoapNote?.vasScore || 3);
|
const [vasScore, setVasScore] = useState<number>(initialSoapNote?.vasScore || 3);
|
||||||
const [isSigned, setIsSigned] = useState<boolean>(initialSoapNote?.status === 'signed');
|
const [isSigned, setIsSigned] = useState<boolean>(initialSoapNote?.status === 'signed');
|
||||||
const [signedTimestamp, setSignedTimestamp] = useState<string | undefined>(initialSoapNote?.signedAt);
|
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: {
|
const handleApplyExtractedSoap = (data: {
|
||||||
vasScore: number;
|
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.');
|
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();
|
clinicalAudio.playSuccess();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
setIsSigned(true);
|
setIsSigned(true);
|
||||||
setSignedTimestamp(now);
|
setSignedTimestamp(now);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(`mediusa_draft_soap_${patient.id}`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const savedNote: SoapNote = {
|
const savedNote: SoapNote = {
|
||||||
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
||||||
tenantId: patient.tenantId,
|
tenantId: patient.tenantId,
|
||||||
@@ -183,7 +245,35 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
onSaveSoapNote(savedNote);
|
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 = () => {
|
const handleCreateSuperbillClick = () => {
|
||||||
clinicalAudio.playSuccess();
|
clinicalAudio.playSuccess();
|
||||||
@@ -244,9 +334,16 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
<ShieldCheck className="w-3.5 h-3.5" /> Signed & Locked
|
<ShieldCheck className="w-3.5 h-3.5" /> Signed & Locked
|
||||||
</span>
|
</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">
|
<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)
|
In Progress (Draft)
|
||||||
</span>
|
</span>
|
||||||
|
{lastAutoSaved && (
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono hidden sm:inline">
|
||||||
|
✓ Auto-saved {lastAutoSaved}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -338,6 +435,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSignChart}
|
onClick={handleSignChart}
|
||||||
disabled={isSigned}
|
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 ${
|
className={`px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition shadow-xs ${
|
||||||
isSigned
|
isSigned
|
||||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 cursor-default'
|
? '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" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
{isSigned ? 'Signed & Locked' : 'Sign & Complete Note'}
|
{isSigned ? 'Signed & Locked' : 'Sign & Complete (⌘S)'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -422,6 +520,26 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
onChange={(e) => setSubjective(e.target.value)}
|
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"
|
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>
|
||||||
<div className="text-[11px] text-slate-400 mt-2">
|
<div className="text-[11px] text-slate-400 mt-2">
|
||||||
Auto-populated from Intake and Ambient Scribe.
|
Auto-populated from Intake and Ambient Scribe.
|
||||||
@@ -449,6 +567,26 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
onChange={(e) => setObjective(e.target.value)}
|
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"
|
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>
|
||||||
<div className="text-[11px] text-slate-400 mt-2">
|
<div className="text-[11px] text-slate-400 mt-2">
|
||||||
Vertebrae selected in Spine Map sync automatically.
|
Vertebrae selected in Spine Map sync automatically.
|
||||||
@@ -473,6 +611,26 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
onChange={(e) => setAssessment(e.target.value)}
|
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"
|
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>
|
||||||
<div className="text-[11px] text-slate-400 mt-2">
|
<div className="text-[11px] text-slate-400 mt-2">
|
||||||
Mapped to ICD-10 diagnostic codes below.
|
Mapped to ICD-10 diagnostic codes below.
|
||||||
@@ -497,6 +655,26 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
onChange={(e) => setPlan(e.target.value)}
|
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"
|
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>
|
||||||
<div className="text-[11px] text-slate-400 mt-2">
|
<div className="text-[11px] text-slate-400 mt-2">
|
||||||
Populates CPT procedure line items on Superbill.
|
Populates CPT procedure line items on Superbill.
|
||||||
|
|||||||
Reference in New Issue
Block a user