feat: polish UX +5% with Cmd+K Command Palette, Web Audio tactile feedback, Patient Clinical Vitals HUD, Fast Macros & Toasts
This commit is contained in:
+155
-5
@@ -35,6 +35,9 @@ import { RetailInventoryView } from '@/components/pos/RetailInventoryView';
|
|||||||
import { MembershipsView } from '@/components/memberships/MembershipsView';
|
import { MembershipsView } from '@/components/memberships/MembershipsView';
|
||||||
import { TelehealthRoom } from '@/components/telehealth/TelehealthRoom';
|
import { TelehealthRoom } from '@/components/telehealth/TelehealthRoom';
|
||||||
import { WaitlistModal } from '@/components/waitlist/WaitlistModal';
|
import { WaitlistModal } from '@/components/waitlist/WaitlistModal';
|
||||||
|
import { CommandPalette } from '@/components/ui/CommandPalette';
|
||||||
|
import { ClinicalToastContainer, ToastMessage } from '@/components/ui/ClinicalToast';
|
||||||
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -51,6 +54,10 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
Search,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -78,6 +85,11 @@ export default function Home() {
|
|||||||
const [waitlist, setWaitlist] = useState<WaitlistEntry[]>(INITIAL_WAITLIST);
|
const [waitlist, setWaitlist] = useState<WaitlistEntry[]>(INITIAL_WAITLIST);
|
||||||
const [isWaitlistOpen, setIsWaitlistOpen] = useState(false);
|
const [isWaitlistOpen, setIsWaitlistOpen] = useState(false);
|
||||||
|
|
||||||
|
// Polish state: Command Palette, Toasts & Audio
|
||||||
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||||
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||||
|
|
||||||
const [activePatient, setActivePatient] = useState<Patient>(INITIAL_PATIENTS[0]);
|
const [activePatient, setActivePatient] = useState<Patient>(INITIAL_PATIENTS[0]);
|
||||||
const [activeSoapNote, setActiveSoapNote] = useState<SoapNote | undefined>(INITIAL_SOAP_NOTES[0]);
|
const [activeSoapNote, setActiveSoapNote] = useState<SoapNote | undefined>(INITIAL_SOAP_NOTES[0]);
|
||||||
const [activeSuperbill, setActiveSuperbill] = useState<Superbill>(INITIAL_SUPERBILLS[0]);
|
const [activeSuperbill, setActiveSuperbill] = useState<Superbill>(INITIAL_SUPERBILLS[0]);
|
||||||
@@ -86,7 +98,52 @@ export default function Home() {
|
|||||||
const activeProviders = INITIAL_PROVIDERS.filter((p) => p.tenantId === activeTenant.id);
|
const activeProviders = INITIAL_PROVIDERS.filter((p) => p.tenantId === activeTenant.id);
|
||||||
const activeProvider = activeProviders[0] || INITIAL_PROVIDERS[0];
|
const activeProvider = activeProviders[0] || INITIAL_PROVIDERS[0];
|
||||||
|
|
||||||
|
// Toast notification helper
|
||||||
|
const addToast = (type: 'success' | 'alert' | 'info', title: string, description?: string) => {
|
||||||
|
const id = `toast-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`;
|
||||||
|
const newToast: ToastMessage = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
||||||
|
};
|
||||||
|
setToasts((prev) => [...prev, newToast]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 4500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismissToast = (id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcut listener for Cmd+K / Ctrl+K
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsCommandPaletteOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleAudio = () => {
|
||||||
|
const next = !audioEnabled;
|
||||||
|
setAudioEnabled(next);
|
||||||
|
clinicalAudio.setEnabled(next);
|
||||||
|
if (next) {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
addToast('info', 'Tactile Audio Active', 'Synthesized Web Audio clicks & chimes enabled');
|
||||||
|
} else {
|
||||||
|
addToast('info', 'Tactile Audio Muted', 'Sound effects silenced');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectAppointment = (apt: Appointment) => {
|
const handleSelectAppointment = (apt: Appointment) => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
const patientMatch = patients.find((p) => p.id === apt.patientId) || patients[0];
|
const patientMatch = patients.find((p) => p.id === apt.patientId) || patients[0];
|
||||||
setActivePatient(patientMatch);
|
setActivePatient(patientMatch);
|
||||||
|
|
||||||
@@ -101,12 +158,15 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateAppointmentStatus = (aptId: string, status: Appointment['status']) => {
|
const handleUpdateAppointmentStatus = (aptId: string, status: Appointment['status']) => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
setAppointments((prev) =>
|
setAppointments((prev) =>
|
||||||
prev.map((a) => (a.id === aptId ? { ...a, status } : a))
|
prev.map((a) => (a.id === aptId ? { ...a, status } : a))
|
||||||
);
|
);
|
||||||
|
addToast('info', 'Encounter Status Updated', `Appointment transitioned to ${status}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveSoapNote = (note: SoapNote) => {
|
const handleSaveSoapNote = (note: SoapNote) => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
setSoapNotes((prev) => {
|
setSoapNotes((prev) => {
|
||||||
const idx = prev.findIndex((s) => s.id === note.id);
|
const idx = prev.findIndex((s) => s.id === note.id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
@@ -117,9 +177,11 @@ export default function Home() {
|
|||||||
return [...prev, note];
|
return [...prev, note];
|
||||||
});
|
});
|
||||||
setActiveSoapNote(note);
|
setActiveSoapNote(note);
|
||||||
|
addToast('success', 'Chart Synced to HIPAA Vault', `${note.patientName} • ${note.status === 'signed' ? 'Signed & Locked' : 'Draft Saved'}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateSuperbillFromNote = (note: SoapNote) => {
|
const handleGenerateSuperbillFromNote = (note: SoapNote) => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
const newSb: Superbill = {
|
const newSb: Superbill = {
|
||||||
id: `sb-${Date.now()}`,
|
id: `sb-${Date.now()}`,
|
||||||
tenantId: activeTenant.id,
|
tenantId: activeTenant.id,
|
||||||
@@ -153,9 +215,11 @@ export default function Home() {
|
|||||||
setSuperbills((prev) => [newSb, ...prev]);
|
setSuperbills((prev) => [newSb, ...prev]);
|
||||||
setActiveSuperbill(newSb);
|
setActiveSuperbill(newSb);
|
||||||
setClinicTab('billing');
|
setClinicTab('billing');
|
||||||
|
addToast('success', 'Superbill Claim Created', `${newSb.invoiceNumber} • $${newSb.totalAmount.toFixed(2)} Fee Schedule`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkPaid = (superbillId: string, method: Superbill['paymentMethod']) => {
|
const handleMarkPaid = (superbillId: string, method: Superbill['paymentMethod']) => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
setSuperbills((prev) =>
|
setSuperbills((prev) =>
|
||||||
prev.map((sb) =>
|
prev.map((sb) =>
|
||||||
sb.id === superbillId
|
sb.id === superbillId
|
||||||
@@ -171,9 +235,11 @@ export default function Home() {
|
|||||||
paymentMethod: method,
|
paymentMethod: method,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
addToast('success', 'Stripe Payment Captured', `Receipt processed via ${method}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePatientBookingComplete = (newAptData: any) => {
|
const handlePatientBookingComplete = (newAptData: any) => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
const newApt: Appointment = {
|
const newApt: Appointment = {
|
||||||
id: `apt-${Date.now()}`,
|
id: `apt-${Date.now()}`,
|
||||||
tenantId: activeTenant.id,
|
tenantId: activeTenant.id,
|
||||||
@@ -181,34 +247,44 @@ export default function Home() {
|
|||||||
...newAptData,
|
...newAptData,
|
||||||
};
|
};
|
||||||
setAppointments((prev) => [newApt, ...prev]);
|
setAppointments((prev) => [newApt, ...prev]);
|
||||||
|
addToast('success', 'Intake & Booking Confirmed', `${newApt.patientName} scheduled for ${newApt.time}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTenant = (newTenant: ClinicTenant) => {
|
const handleAddTenant = (newTenant: ClinicTenant) => {
|
||||||
setTenants((prev) => [...prev, newTenant]);
|
setTenants((prev) => [...prev, newTenant]);
|
||||||
|
addToast('success', 'New Clinic Deployed', `${newTenant.name} onboarded to Mediusa OS`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwitchTenant = (tenantId: string) => {
|
const handleSwitchTenant = (tenantId: string) => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
setActiveTenantId(tenantId);
|
setActiveTenantId(tenantId);
|
||||||
setPortalMode('clinic');
|
setPortalMode('clinic');
|
||||||
setClinicTab('calendar');
|
setClinicTab('calendar');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateProductStock = (productId: string, newStock: number) => {
|
const handleUpdateProductStock = (productId: string, newStock: number) => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
setProducts((prev) =>
|
setProducts((prev) =>
|
||||||
prev.map((p) => (p.id === productId ? { ...p, stockQty: newStock } : p))
|
prev.map((p) => (p.id === productId ? { ...p, stockQty: newStock } : p))
|
||||||
);
|
);
|
||||||
|
addToast('info', 'POS Inventory Updated', `Stock quantity adjusted to ${newStock}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnrollPatientInMembership = (patientId: string, planName: string) => {
|
const handleEnrollPatientInMembership = (patientId: string, planName: string) => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
setPatients((prev) =>
|
setPatients((prev) =>
|
||||||
prev.map((p) => (p.id === patientId ? { ...p, activeMembership: planName } : p))
|
prev.map((p) => (p.id === patientId ? { ...p, activeMembership: planName } : p))
|
||||||
);
|
);
|
||||||
|
addToast('success', 'Membership Enrolled', `Patient subscribed to ${planName}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoFillWaitlistPatient = (entryId: string) => {
|
const handleAutoFillWaitlistPatient = (entryId: string) => {
|
||||||
|
clinicalAudio.playPing();
|
||||||
|
const entry = waitlist.find((w) => w.id === entryId);
|
||||||
setWaitlist((prev) =>
|
setWaitlist((prev) =>
|
||||||
prev.map((w) => (w.id === entryId ? { ...w, status: 'notified' } : w))
|
prev.map((w) => (w.id === entryId ? { ...w, status: 'notified' } : w))
|
||||||
);
|
);
|
||||||
|
addToast('alert', 'Waitlist SMS Dispatched', `Slot opening sent to ${entry?.patientName || 'patient'}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -226,7 +302,7 @@ export default function Home() {
|
|||||||
<div className="text-xs font-black tracking-tight text-slate-900 flex items-center gap-1.5">
|
<div className="text-xs font-black tracking-tight text-slate-900 flex items-center gap-1.5">
|
||||||
<span>MEDIUSA CLINIC OS</span>
|
<span>MEDIUSA CLINIC OS</span>
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 font-bold border border-sky-200">
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 font-bold border border-sky-200">
|
||||||
CLINICAL v2.4
|
CLINICAL v2.5
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 font-mono flex items-center gap-1 mt-0.5">
|
<div className="text-xs text-slate-500 font-mono flex items-center gap-1 mt-0.5">
|
||||||
@@ -236,6 +312,36 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Command Palette Trigger (Cmd+K) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setIsCommandPaletteOpen(true);
|
||||||
|
}}
|
||||||
|
className="hidden xl:flex items-center gap-2 px-3 py-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 rounded-lg text-slate-500 text-xs transition shadow-2xs"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span>Search patients or actions...</span>
|
||||||
|
<kbd className="px-1.5 py-0.5 text-[9px] font-mono bg-white border border-slate-200 rounded text-slate-500 font-bold">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Audio Toggle Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleAudio}
|
||||||
|
title={audioEnabled ? 'Tactile Audio Active (Click to Mute)' : 'Tactile Audio Muted (Click to Enable)'}
|
||||||
|
className={`p-1.5 rounded-lg border text-xs transition flex items-center gap-1 shadow-2xs ${
|
||||||
|
audioEnabled
|
||||||
|
? 'bg-sky-50 border-sky-200 text-sky-700 hover:bg-sky-100'
|
||||||
|
: 'bg-slate-100 border-slate-200 text-slate-400 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{audioEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Clinic Dropdown */}
|
{/* Clinic Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
@@ -257,7 +363,11 @@ export default function Home() {
|
|||||||
<span className="text-[10px] uppercase font-bold text-slate-500 px-1.5">Role:</span>
|
<span className="text-[10px] uppercase font-bold text-slate-500 px-1.5">Role:</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStaffRole('doctor')}
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setStaffRole('doctor');
|
||||||
|
addToast('info', 'Role: Attending Doctor (D.C.)', 'Full clinical SOAP, 2D spine & telehealth unlocked');
|
||||||
|
}}
|
||||||
className={`px-2 py-1 rounded text-xs font-bold transition ${
|
className={`px-2 py-1 rounded text-xs font-bold transition ${
|
||||||
staffRole === 'doctor'
|
staffRole === 'doctor'
|
||||||
? 'bg-white text-sky-800 shadow-2xs border border-slate-200'
|
? 'bg-white text-sky-800 shadow-2xs border border-slate-200'
|
||||||
@@ -270,10 +380,12 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
setStaffRole('front_desk');
|
setStaffRole('front_desk');
|
||||||
if (clinicTab === 'charting' || clinicTab === 'telehealth') {
|
if (clinicTab === 'charting' || clinicTab === 'telehealth') {
|
||||||
setClinicTab('calendar');
|
setClinicTab('calendar');
|
||||||
}
|
}
|
||||||
|
addToast('alert', 'Role: Front Desk (HIPAA Safe)', 'Protected medical notes locked for privacy');
|
||||||
}}
|
}}
|
||||||
className={`px-2 py-1 rounded text-xs font-bold transition ${
|
className={`px-2 py-1 rounded text-xs font-bold transition ${
|
||||||
staffRole === 'front_desk'
|
staffRole === 'front_desk'
|
||||||
@@ -286,7 +398,11 @@ export default function Home() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStaffRole('billing_admin')}
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setStaffRole('billing_admin');
|
||||||
|
addToast('info', 'Role: Billing Administrator', 'Superbill claims and memberships prioritized');
|
||||||
|
}}
|
||||||
className={`px-2 py-1 rounded text-xs font-bold transition ${
|
className={`px-2 py-1 rounded text-xs font-bold transition ${
|
||||||
staffRole === 'billing_admin'
|
staffRole === 'billing_admin'
|
||||||
? 'bg-white text-emerald-800 shadow-2xs border border-slate-200'
|
? 'bg-white text-emerald-800 shadow-2xs border border-slate-200'
|
||||||
@@ -303,7 +419,10 @@ export default function Home() {
|
|||||||
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200 text-xs font-semibold">
|
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200 text-xs font-semibold">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPortalMode('clinic')}
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setPortalMode('clinic');
|
||||||
|
}}
|
||||||
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 transition ${
|
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 transition ${
|
||||||
portalMode === 'clinic'
|
portalMode === 'clinic'
|
||||||
? 'bg-sky-700 text-white font-bold shadow-xs'
|
? 'bg-sky-700 text-white font-bold shadow-xs'
|
||||||
@@ -316,7 +435,10 @@ export default function Home() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPortalMode('patient')}
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setPortalMode('patient');
|
||||||
|
}}
|
||||||
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 transition ${
|
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 transition ${
|
||||||
portalMode === 'patient'
|
portalMode === 'patient'
|
||||||
? 'bg-sky-700 text-white font-bold shadow-xs'
|
? 'bg-sky-700 text-white font-bold shadow-xs'
|
||||||
@@ -555,6 +677,34 @@ export default function Home() {
|
|||||||
onAutoFillPatient={handleAutoFillWaitlistPatient}
|
onAutoFillPatient={handleAutoFillWaitlistPatient}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Global Command Palette (Cmd+K) */}
|
||||||
|
<CommandPalette
|
||||||
|
isOpen={isCommandPaletteOpen}
|
||||||
|
onClose={() => setIsCommandPaletteOpen(false)}
|
||||||
|
patients={patients}
|
||||||
|
onSelectPatient={(p) => {
|
||||||
|
setActivePatient(p);
|
||||||
|
const existing = soapNotes.find((s) => s.patientId === p.id);
|
||||||
|
setActiveSoapNote(existing);
|
||||||
|
}}
|
||||||
|
onNavigateTab={(tab) => setClinicTab(tab)}
|
||||||
|
onOpenWaitlist={() => setIsWaitlistOpen(true)}
|
||||||
|
onSetStaffRole={(role) => {
|
||||||
|
setStaffRole(role);
|
||||||
|
if (role === 'front_desk' && (clinicTab === 'charting' || clinicTab === 'telehealth')) {
|
||||||
|
setClinicTab('calendar');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onToggleAudio={handleToggleAudio}
|
||||||
|
audioEnabled={audioEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hospital Clinical Toast Notification System */}
|
||||||
|
<ClinicalToastContainer
|
||||||
|
toasts={toasts}
|
||||||
|
onDismiss={handleDismissToast}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Hospital Footer */}
|
{/* Hospital Footer */}
|
||||||
<footer className="bg-white border-t border-slate-200 py-4 px-6 text-center text-xs text-slate-500">
|
<footer className="bg-white border-t border-slate-200 py-4 px-6 text-center text-xs text-slate-500">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between max-w-7xl mx-auto gap-2">
|
<div className="flex flex-col sm:flex-row items-center justify-between max-w-7xl mx-auto gap-2">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from '@/types/clinical';
|
} from '@/types/clinical';
|
||||||
import { SpineVisualizer } from '@/components/ui/SpineVisualizer';
|
import { SpineVisualizer } from '@/components/ui/SpineVisualizer';
|
||||||
import { AmbientAudioRecorder } from '@/components/ui/AmbientAudioRecorder';
|
import { AmbientAudioRecorder } from '@/components/ui/AmbientAudioRecorder';
|
||||||
|
import { PatientClinicalHud } from '@/components/ui/PatientClinicalHud';
|
||||||
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
import { STANDARD_CPT_CODES, STANDARD_ICD10_CODES, DISCIPLINE_PRESETS } from '@/lib/mock-data';
|
import { STANDARD_CPT_CODES, STANDARD_ICD10_CODES, DISCIPLINE_PRESETS } from '@/lib/mock-data';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Video,
|
Video,
|
||||||
Layers,
|
Layers,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SoapChartEditorProps {
|
interface SoapChartEditorProps {
|
||||||
@@ -109,6 +112,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSwitchDiscipline = (newDiscipline: ClinicalDiscipline) => {
|
const handleSwitchDiscipline = (newDiscipline: ClinicalDiscipline) => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
setDiscipline(newDiscipline);
|
setDiscipline(newDiscipline);
|
||||||
const preset = DISCIPLINE_PRESETS[newDiscipline];
|
const preset = DISCIPLINE_PRESETS[newDiscipline];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
@@ -126,6 +130,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleAdjustment = (entry: SpinalAdjustmentEntry) => {
|
const handleToggleAdjustment = (entry: SpinalAdjustmentEntry) => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
setAdjustments((prev) => {
|
setAdjustments((prev) => {
|
||||||
const exists = prev.some((a) => a.vertebra === entry.vertebra);
|
const exists = prev.some((a) => a.vertebra === entry.vertebra);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -137,6 +142,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCloneLastNote = () => {
|
const handleCloneLastNote = () => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
setSubjective('Patient reports continued symptom improvement following last spinal adjustment. Morning stiffness resolved within 10 minutes. Current pain rated 3/10.');
|
setSubjective('Patient reports continued symptom improvement following last spinal adjustment. Morning stiffness resolved within 10 minutes. Current pain rated 3/10.');
|
||||||
setObjective('Palpation reveals decreased tone in lumbar paraspinals. Persistent fixation noted at L4-L5 with right sacral torsion.');
|
setObjective('Palpation reveals decreased tone in lumbar paraspinals. Persistent fixation noted at L4-L5 with right sacral torsion.');
|
||||||
setAssessment('Care plan compliance high. Significant restoration of active range of motion noted.');
|
setAssessment('Care plan compliance high. Significant restoration of active range of motion noted.');
|
||||||
@@ -144,6 +150,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSignChart = () => {
|
const handleSignChart = () => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
setIsSigned(true);
|
setIsSigned(true);
|
||||||
setSignedTimestamp(now);
|
setSignedTimestamp(now);
|
||||||
@@ -174,6 +181,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSuperbillClick = () => {
|
const handleCreateSuperbillClick = () => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
const savedNote: SoapNote = {
|
const savedNote: SoapNote = {
|
||||||
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
||||||
tenantId: patient.tenantId,
|
tenantId: patient.tenantId,
|
||||||
@@ -193,14 +201,18 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
icd10Codes: selectedIcd10,
|
icd10Codes: selectedIcd10,
|
||||||
cptCodes: selectedCpt,
|
cptCodes: selectedCpt,
|
||||||
signedAt: signedTimestamp,
|
signedAt: signedTimestamp,
|
||||||
signedBy: `${provider.name}, ${provider.credentials}`,
|
signedBy: isSigned ? `${provider.name}, ${provider.credentials}` : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onSaveSoapNote(savedNote);
|
||||||
onGenerateSuperbill(savedNote);
|
onGenerateSuperbill(savedNote);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
|
{/* Patient Clinical HUD Ribbon */}
|
||||||
|
<PatientClinicalHud patient={patient} />
|
||||||
|
|
||||||
{/* Top Patient Header Bar */}
|
{/* Top Patient Header Bar */}
|
||||||
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -218,14 +230,10 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
<h3 className="text-lg font-bold text-slate-900">
|
<h3 className="text-lg font-bold text-slate-900">
|
||||||
{patient.firstName} {patient.lastName}
|
{patient.firstName} {patient.lastName}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-sky-50 text-sky-800 border border-sky-200 font-semibold">
|
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-800 text-[10px] font-semibold">
|
||||||
Care Plan: Visit {patient.carePlan.completedVisits + 1} of {patient.carePlan.totalVisits}
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
</span>
|
<span>HIPAA Vault Sync</span>
|
||||||
{patient.activeMembership && (
|
</div>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-800 border border-emerald-200 font-bold">
|
|
||||||
⭐ {patient.activeMembership}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isSigned ? (
|
{isSigned ? (
|
||||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 font-semibold flex items-center gap-1">
|
<span className="text-xs px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 font-semibold flex items-center gap-1">
|
||||||
<ShieldCheck className="w-3.5 h-3.5" /> Signed & Locked
|
<ShieldCheck className="w-3.5 h-3.5" /> Signed & Locked
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: string;
|
||||||
|
type: 'success' | 'alert' | 'info';
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClinicalToastProps {
|
||||||
|
toasts: ToastMessage[];
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClinicalToastContainer: React.FC<ClinicalToastProps> = ({ toasts, onDismiss }) => {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside aria-label="Clinical System Notifications" className="fixed bottom-6 right-6 z-50 flex flex-col space-y-2 max-w-sm w-full pointer-events-none">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`pointer-events-auto flex items-start gap-3 p-3.5 rounded-xl border shadow-lg backdrop-blur-md transition-all duration-300 transform translate-y-0 ${
|
||||||
|
t.type === 'success'
|
||||||
|
? 'bg-white/95 border-emerald-200 text-slate-800'
|
||||||
|
: t.type === 'alert'
|
||||||
|
? 'bg-white/95 border-amber-200 text-slate-800'
|
||||||
|
: 'bg-white/95 border-sky-200 text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 shrink-0">
|
||||||
|
{t.type === 'success' && <CheckCircle2 className="w-5 h-5 text-emerald-600" />}
|
||||||
|
{t.type === 'alert' && <AlertTriangle className="w-5 h-5 text-amber-600" />}
|
||||||
|
{t.type === 'info' && <Info className="w-5 h-5 text-sky-600" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-slate-900 leading-tight">{t.title}</p>
|
||||||
|
{t.description && (
|
||||||
|
<p className="text-[11px] text-slate-600 mt-0.5 leading-snug">{t.description}</p>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-slate-400 mt-1 block">{t.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onDismiss(t.id)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
Video,
|
||||||
|
ShoppingBag,
|
||||||
|
Repeat,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Activity,
|
||||||
|
User,
|
||||||
|
Shield,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
X,
|
||||||
|
Sparkles,
|
||||||
|
Command,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Patient, StaffRole } from '@/types/clinical';
|
||||||
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
|
|
||||||
|
export interface CommandItem {
|
||||||
|
id: string;
|
||||||
|
category: 'Patients' | 'Navigation' | 'Actions' | 'Staff Role' | 'Settings';
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
badge?: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
patients: Patient[];
|
||||||
|
onSelectPatient: (p: Patient) => void;
|
||||||
|
onNavigateTab: (tab: 'calendar' | 'charting' | 'telehealth' | 'retail' | 'memberships' | 'billing' | 'retention') => void;
|
||||||
|
onOpenWaitlist: () => void;
|
||||||
|
onSetStaffRole: (role: StaffRole) => void;
|
||||||
|
onToggleAudio: () => void;
|
||||||
|
audioEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
patients,
|
||||||
|
onSelectPatient,
|
||||||
|
onNavigateTab,
|
||||||
|
onOpenWaitlist,
|
||||||
|
onSetStaffRole,
|
||||||
|
onToggleAudio,
|
||||||
|
audioEnabled,
|
||||||
|
}) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
setQuery('');
|
||||||
|
setSelectedIndex(0);
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Generate commands list
|
||||||
|
const baseCommands: CommandItem[] = [
|
||||||
|
// Patients
|
||||||
|
...patients.map((p) => ({
|
||||||
|
id: `patient-${p.id}`,
|
||||||
|
category: 'Patients' as const,
|
||||||
|
title: `${p.firstName} ${p.lastName}`,
|
||||||
|
subtitle: `${p.carePlan.targetCondition} • Visit ${p.carePlan.completedVisits}/${p.carePlan.totalVisits}`,
|
||||||
|
badge: p.status === 'dropout_risk' ? 'Dropout Risk' : 'Active Patient',
|
||||||
|
icon: <User className="w-4 h-4 text-sky-600" />,
|
||||||
|
action: () => {
|
||||||
|
onSelectPatient(p);
|
||||||
|
onNavigateTab('charting');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
{
|
||||||
|
id: 'nav-calendar',
|
||||||
|
category: 'Navigation',
|
||||||
|
title: 'Schedule & Operatory Calendar',
|
||||||
|
subtitle: 'View live provider schedules and patient arrival states',
|
||||||
|
icon: <Calendar className="w-4 h-4 text-sky-600" />,
|
||||||
|
action: () => {
|
||||||
|
onNavigateTab('calendar');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-charting',
|
||||||
|
category: 'Navigation',
|
||||||
|
title: 'Clinical SOAP Charting & Spine Visualizer',
|
||||||
|
subtitle: '2D anatomical subluxation clicks and ambient AI scribe',
|
||||||
|
icon: <FileText className="w-4 h-4 text-emerald-600" />,
|
||||||
|
action: () => {
|
||||||
|
onNavigateTab('charting');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-telehealth',
|
||||||
|
category: 'Navigation',
|
||||||
|
title: 'Virtual Telehealth Consultation Suite',
|
||||||
|
subtitle: 'HIPAA-compliant video consult with live side-by-side notes',
|
||||||
|
icon: <Video className="w-4 h-4 text-blue-600" />,
|
||||||
|
action: () => {
|
||||||
|
onNavigateTab('telehealth');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-retail',
|
||||||
|
category: 'Navigation',
|
||||||
|
title: 'Front-Desk Supplement & Retail POS',
|
||||||
|
subtitle: 'Stock inventory, barcode search, Stripe terminal checkout',
|
||||||
|
icon: <ShoppingBag className="w-4 h-4 text-amber-600" />,
|
||||||
|
action: () => {
|
||||||
|
onNavigateTab('retail');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-memberships',
|
||||||
|
category: 'Navigation',
|
||||||
|
title: 'Recurring Memberships & Visit Packages',
|
||||||
|
subtitle: '$89/mo monthly wellness club and pre-paid visit blocks',
|
||||||
|
icon: <Repeat className="w-4 h-4 text-emerald-600" />,
|
||||||
|
action: () => {
|
||||||
|
onNavigateTab('memberships');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-billing',
|
||||||
|
category: 'Navigation',
|
||||||
|
title: 'Medical Superbill & CMS-1500 Billing',
|
||||||
|
subtitle: 'Generate reimbursement claim PDF and Stripe copay checkout',
|
||||||
|
icon: <DollarSign className="w-4 h-4 text-slate-700" />,
|
||||||
|
action: () => {
|
||||||
|
onNavigateTab('billing');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-retention',
|
||||||
|
category: 'Navigation',
|
||||||
|
title: 'Care Plan Retention & Dropout Radar',
|
||||||
|
subtitle: 'Autonomous recovery SMS dispatch for lagging patients',
|
||||||
|
icon: <Users className="w-4 h-4 text-rose-600" />,
|
||||||
|
action: () => {
|
||||||
|
onNavigateTab('retention');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fast Actions
|
||||||
|
{
|
||||||
|
id: 'action-waitlist',
|
||||||
|
category: 'Actions',
|
||||||
|
title: 'Cancellation Waitlist Queue',
|
||||||
|
subtitle: 'Dispatch SMS alerts to queued patients for open slots',
|
||||||
|
badge: 'Waitlist',
|
||||||
|
icon: <Activity className="w-4 h-4 text-amber-600" />,
|
||||||
|
action: () => {
|
||||||
|
onOpenWaitlist();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Staff Roles
|
||||||
|
{
|
||||||
|
id: 'role-doctor',
|
||||||
|
category: 'Staff Role',
|
||||||
|
title: 'Switch Role: Doctor (D.C.)',
|
||||||
|
subtitle: 'Full access to clinical SOAP, 2D spine, AI scribe & telehealth',
|
||||||
|
icon: <Shield className="w-4 h-4 text-sky-600" />,
|
||||||
|
action: () => {
|
||||||
|
onSetStaffRole('doctor');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'role-front-desk',
|
||||||
|
category: 'Staff Role',
|
||||||
|
title: 'Switch Role: Front Desk (HIPAA-Safe Mode)',
|
||||||
|
subtitle: 'Locks medical charts; grants schedule, POS & waitlist access',
|
||||||
|
icon: <Shield className="w-4 h-4 text-emerald-600" />,
|
||||||
|
action: () => {
|
||||||
|
onSetStaffRole('front_desk');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'role-billing-admin',
|
||||||
|
category: 'Staff Role',
|
||||||
|
title: 'Switch Role: Billing Administrator',
|
||||||
|
subtitle: 'Direct focus on superbills, claims, packages & recurring clubs',
|
||||||
|
icon: <Shield className="w-4 h-4 text-indigo-600" />,
|
||||||
|
action: () => {
|
||||||
|
onSetStaffRole('billing_admin');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
{
|
||||||
|
id: 'setting-audio',
|
||||||
|
category: 'Settings',
|
||||||
|
title: audioEnabled ? 'Mute Tactile Clinical Audio' : 'Enable Tactile Clinical Audio',
|
||||||
|
subtitle: 'Web Audio synthesizer feedback for spine clicks and payments',
|
||||||
|
icon: audioEnabled ? <VolumeX className="w-4 h-4 text-slate-600" /> : <Volume2 className="w-4 h-4 text-emerald-600" />,
|
||||||
|
action: () => {
|
||||||
|
onToggleAudio();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = baseCommands.filter((cmd) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
cmd.title.toLowerCase().includes(q) ||
|
||||||
|
(cmd.subtitle && cmd.subtitle.toLowerCase().includes(q)) ||
|
||||||
|
cmd.category.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle keyboard navigation inside the list
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % (filtered.length || 1));
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + filtered.length) % (filtered.length || 1));
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered[selectedIndex]) {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
filtered[selectedIndex].action();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Command Palette"
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4 bg-slate-900/40 backdrop-blur-sm transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden transform transition-all flex flex-col max-h-[80vh]"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{/* Search Header */}
|
||||||
|
<div className="flex items-center px-4 py-3.5 border-b border-slate-100 bg-slate-50/50">
|
||||||
|
<Search className="w-5 h-5 text-slate-400 mr-3 shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}}
|
||||||
|
placeholder="Type a patient name, clinical command, or view... (Cmd+K)"
|
||||||
|
className="w-full text-sm text-slate-800 placeholder-slate-400 bg-transparent border-none focus:outline-none focus:ring-0"
|
||||||
|
/>
|
||||||
|
<kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-mono font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results List */}
|
||||||
|
<div ref={listRef} className="overflow-y-auto p-2 divide-y divide-slate-50 space-y-1">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-slate-400 text-xs">
|
||||||
|
No matching patients or clinical commands found for “{query}”
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((item, idx) => {
|
||||||
|
const isSelected = idx === selectedIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
item.action();
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setSelectedIndex(idx)}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-left transition-all ${
|
||||||
|
isSelected ? 'bg-sky-50/80 border border-sky-200' : 'hover:bg-slate-50 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-lg shrink-0 ${
|
||||||
|
isSelected ? 'bg-white shadow-xs' : 'bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-slate-800 truncate">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{item.badge && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-slate-100 text-slate-600">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.subtitle && (
|
||||||
|
<p className="text-[11px] text-slate-500 truncate mt-0.5">
|
||||||
|
{item.subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-[10px] font-medium text-slate-400 uppercase tracking-wider shrink-0 ml-2">
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer shortcuts helper */}
|
||||||
|
<div className="px-4 py-2.5 bg-slate-50 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white border border-slate-200 rounded font-mono text-[9px]">↑</kbd>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white border border-slate-200 rounded font-mono text-[9px]">↓</kbd>
|
||||||
|
Navigate
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white border border-slate-200 rounded font-mono text-[9px]">↵</kbd>
|
||||||
|
Select
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-sky-700 font-medium">
|
||||||
|
<Sparkles className="w-3 h-3" /> Mediusa Quick Command Hub
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Patient, PatientVitals } from '@/types/clinical';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Heart,
|
||||||
|
Wind,
|
||||||
|
Flame,
|
||||||
|
AlertTriangle,
|
||||||
|
ShieldCheck,
|
||||||
|
CreditCard,
|
||||||
|
Edit2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
|
|
||||||
|
interface PatientClinicalHudProps {
|
||||||
|
patient: Patient;
|
||||||
|
onUpdateVitals?: (patientId: string, newVitals: PatientVitals) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PatientClinicalHud: React.FC<PatientClinicalHudProps> = ({
|
||||||
|
patient,
|
||||||
|
onUpdateVitals,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [isEditingVitals, setIsEditingVitals] = useState(false);
|
||||||
|
|
||||||
|
const vitals = patient.vitals || {
|
||||||
|
bloodPressure: '120/80',
|
||||||
|
heartRate: 72,
|
||||||
|
temperature: '98.6 °F',
|
||||||
|
oxygenSat: 99,
|
||||||
|
painLevel: 4,
|
||||||
|
bmi: '23.5',
|
||||||
|
allergies: [],
|
||||||
|
contraindications: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [editBp, setEditBp] = useState(vitals.bloodPressure);
|
||||||
|
const [editHr, setEditHr] = useState(vitals.heartRate);
|
||||||
|
const [editO2, setEditO2] = useState(vitals.oxygenSat);
|
||||||
|
const [editPain, setEditPain] = useState(vitals.painLevel);
|
||||||
|
|
||||||
|
const handleSaveVitals = () => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
if (onUpdateVitals) {
|
||||||
|
onUpdateVitals(patient.id, {
|
||||||
|
...vitals,
|
||||||
|
bloodPressure: editBp,
|
||||||
|
heartRate: Number(editHr),
|
||||||
|
oxygenSat: Number(editO2),
|
||||||
|
painLevel: Number(editPain),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsEditingVitals(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const carePlanProgress = Math.round(
|
||||||
|
(patient.carePlan.completedVisits / patient.carePlan.totalVisits) * 100
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Patient Clinical HUD" className="mb-4 bg-white border border-slate-200 rounded-2xl shadow-xs overflow-hidden">
|
||||||
|
{/* Top Banner Row */}
|
||||||
|
<div className="px-4 py-2.5 bg-slate-50/80 border-b border-slate-200 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-sky-100 text-sky-700 font-bold text-xs flex items-center justify-center border border-sky-200">
|
||||||
|
{patient.firstName[0]}
|
||||||
|
{patient.lastName[0]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xs font-bold text-slate-900">
|
||||||
|
{patient.firstName} {patient.lastName}
|
||||||
|
</h2>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono bg-slate-200/70 px-1.5 py-0.5 rounded">
|
||||||
|
MRN-{patient.id.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
DOB: {patient.dob} ({patient.gender})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-600 truncate max-w-md">
|
||||||
|
<span className="font-semibold text-slate-700">Chief Complaint:</span>{' '}
|
||||||
|
{patient.chiefComplaint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Active Membership Badge */}
|
||||||
|
{patient.activeMembership && (
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 text-[10px] font-semibold">
|
||||||
|
<CreditCard className="w-3 h-3" />
|
||||||
|
<span>{patient.activeMembership}</span>
|
||||||
|
{patient.packageCreditsRemaining !== undefined && (
|
||||||
|
<span className="bg-emerald-200/80 px-1 rounded-full text-[9px]">
|
||||||
|
{patient.packageCreditsRemaining} left
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Insurance Pill */}
|
||||||
|
<div className="hidden md:flex items-center gap-1 px-2.5 py-1 rounded-full bg-sky-50 border border-sky-200 text-sky-800 text-[10px] font-medium">
|
||||||
|
<ShieldCheck className="w-3 h-3 text-sky-600" />
|
||||||
|
<span>{patient.insuranceName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-lg hover:bg-slate-200/60 text-slate-500 text-xs flex items-center gap-1 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Clinical Data Row */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-3.5 bg-white grid grid-cols-1 lg:grid-cols-12 gap-3 text-xs">
|
||||||
|
{/* Left: Vitals Gauges (Col 5) */}
|
||||||
|
<div className="lg:col-span-5 flex flex-wrap items-center gap-2">
|
||||||
|
{/* BP */}
|
||||||
|
<div className="flex-1 min-w-[100px] p-2 rounded-xl bg-slate-50 border border-slate-200 flex items-center gap-2.5">
|
||||||
|
<Activity className="w-4 h-4 text-sky-600 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wider block font-medium">
|
||||||
|
BP
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-slate-800 text-xs">
|
||||||
|
{vitals.bloodPressure}{' '}
|
||||||
|
<span className="text-[9px] font-normal text-slate-400">mmHg</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heart Rate */}
|
||||||
|
<div className="flex-1 min-w-[90px] p-2 rounded-xl bg-slate-50 border border-slate-200 flex items-center gap-2.5">
|
||||||
|
<Heart className="w-4 h-4 text-rose-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wider block font-medium">
|
||||||
|
HR
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-slate-800 text-xs">
|
||||||
|
{vitals.heartRate}{' '}
|
||||||
|
<span className="text-[9px] font-normal text-slate-400">bpm</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SpO2 */}
|
||||||
|
<div className="flex-1 min-w-[85px] p-2 rounded-xl bg-slate-50 border border-slate-200 flex items-center gap-2.5">
|
||||||
|
<Wind className="w-4 h-4 text-teal-600 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wider block font-medium">
|
||||||
|
SpO₂
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-slate-800 text-xs">
|
||||||
|
{vitals.oxygenSat}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pain Scale */}
|
||||||
|
<div className="flex-1 min-w-[90px] p-2 rounded-xl bg-slate-50 border border-slate-200 flex items-center gap-2.5">
|
||||||
|
<Flame className="w-4 h-4 text-amber-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wider block font-medium">
|
||||||
|
Pain VAS
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-amber-700 text-xs">
|
||||||
|
{vitals.painLevel}/10
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setIsEditingVitals(true);
|
||||||
|
}}
|
||||||
|
title="Edit Patient Vitals"
|
||||||
|
className="p-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Care Plan Progress (Col 3) */}
|
||||||
|
<div className="lg:col-span-3 p-2.5 rounded-xl bg-sky-50/60 border border-sky-100 flex flex-col justify-center">
|
||||||
|
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||||
|
<span className="font-semibold text-sky-900">Care Plan Cadence</span>
|
||||||
|
<span className="font-bold text-sky-800">
|
||||||
|
Visit {patient.carePlan.completedVisits} of {patient.carePlan.totalVisits}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-sky-200/80 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-sky-600 h-1.5 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${carePlanProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-sky-700 mt-1 truncate">
|
||||||
|
{patient.carePlan.frequency} • {patient.carePlan.targetCondition}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Clinical Red Flags & Contraindications (Col 4) */}
|
||||||
|
<div className="lg:col-span-4 p-2.5 rounded-xl bg-amber-50/70 border border-amber-200/80 flex flex-col justify-center">
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-900 font-semibold text-[11px] mb-1">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-amber-600 shrink-0" />
|
||||||
|
<span>Clinical Precautions & Allergies</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{vitals.contraindications.map((c, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="text-[10px] bg-amber-100/90 text-amber-900 border border-amber-300 px-1.5 py-0.5 rounded font-medium"
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{vitals.allergies.length > 0 ? (
|
||||||
|
vitals.allergies.map((a, i) => (
|
||||||
|
<span
|
||||||
|
key={`all-${i}`}
|
||||||
|
className="text-[10px] bg-rose-100 text-rose-800 border border-rose-300 px-1.5 py-0.5 rounded font-medium"
|
||||||
|
>
|
||||||
|
Allergy: {a}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-slate-500 italic">No drug allergies listed</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Edit Vitals Modal */}
|
||||||
|
{isEditingVitals && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Update Patient Vitals"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-xs"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 w-full max-w-sm p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4 text-sky-600" />
|
||||||
|
<h4 className="text-sm font-bold text-slate-900">Update Clinical Vitals</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditingVitals(false)}
|
||||||
|
className="text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">
|
||||||
|
Blood Pressure (mmHg)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editBp}
|
||||||
|
onChange={(e) => setEditBp(e.target.value)}
|
||||||
|
placeholder="120/80"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-slate-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">Heart Rate (bpm)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editHr}
|
||||||
|
onChange={(e) => setEditHr(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-slate-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">SpO₂ Oxygen (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editO2}
|
||||||
|
onChange={(e) => setEditO2(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-slate-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">
|
||||||
|
VAS Pain Level (0 - 10)
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
value={editPain}
|
||||||
|
onChange={(e) => setEditPain(Number(e.target.value))}
|
||||||
|
className="w-full accent-amber-600"
|
||||||
|
/>
|
||||||
|
<span className="font-bold text-amber-700 w-8 text-center">{editPain}/10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditingVitals(false)}
|
||||||
|
className="px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveVitals}
|
||||||
|
className="px-4 py-1.5 text-xs font-semibold text-white bg-sky-600 hover:bg-sky-700 rounded-lg flex items-center gap-1.5 shadow-xs transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" /> Save Vitals
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { SpinalAdjustmentEntry } from '@/types/clinical';
|
import { SpinalAdjustmentEntry } from '@/types/clinical';
|
||||||
import { Check, X, Zap, Activity } from 'lucide-react';
|
import { Check, X, Zap, Activity } from 'lucide-react';
|
||||||
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
|
|
||||||
interface SpineVisualizerProps {
|
interface SpineVisualizerProps {
|
||||||
adjustments: SpinalAdjustmentEntry[];
|
adjustments: SpinalAdjustmentEntry[];
|
||||||
@@ -93,6 +94,7 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
|||||||
|
|
||||||
const handleOpenModal = (v: VertebraDef) => {
|
const handleOpenModal = (v: VertebraDef) => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
|
clinicalAudio.playClick();
|
||||||
const existing = getAdjustment(v.id);
|
const existing = getAdjustment(v.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setActiveListing(existing.listing);
|
setActiveListing(existing.listing);
|
||||||
@@ -106,6 +108,7 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
|||||||
|
|
||||||
const handleSaveListing = () => {
|
const handleSaveListing = () => {
|
||||||
if (!selectedVertebra) return;
|
if (!selectedVertebra) return;
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
onToggleAdjustment({
|
onToggleAdjustment({
|
||||||
vertebra: selectedVertebra.id,
|
vertebra: selectedVertebra.id,
|
||||||
region: selectedVertebra.region,
|
region: selectedVertebra.region,
|
||||||
@@ -117,6 +120,7 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveListing = (vertebraId: string) => {
|
const handleRemoveListing = (vertebraId: string) => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
const existing = getAdjustment(vertebraId);
|
const existing = getAdjustment(vertebraId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
onToggleAdjustment(existing);
|
onToggleAdjustment(existing);
|
||||||
@@ -288,13 +292,28 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
|||||||
{/* Quick Subluxation Preset Macros */}
|
{/* Quick Subluxation Preset Macros */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="mt-4 pt-3 border-t border-slate-200">
|
<div className="mt-4 pt-3 border-t border-slate-200">
|
||||||
<div className="text-[11px] font-bold text-slate-600 uppercase tracking-wider mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] font-bold text-slate-600 uppercase tracking-wider">
|
||||||
Clinical Fast Macros
|
Clinical Fast Macros
|
||||||
|
</span>
|
||||||
|
{adjustments.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
adjustments.forEach((adj) => onToggleAdjustment(adj));
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-semibold text-rose-600 hover:text-rose-700"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
onToggleAdjustment({
|
onToggleAdjustment({
|
||||||
vertebra: 'C1',
|
vertebra: 'C1',
|
||||||
region: 'Cervical',
|
region: 'Cervical',
|
||||||
@@ -302,19 +321,20 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
|||||||
technique: 'Diversified',
|
technique: 'Diversified',
|
||||||
});
|
});
|
||||||
onToggleAdjustment({
|
onToggleAdjustment({
|
||||||
vertebra: 'C5',
|
vertebra: 'C2',
|
||||||
region: 'Cervical',
|
region: 'Cervical',
|
||||||
listing: 'Bilateral Facet Fixation',
|
listing: 'Right Rotation Fixation',
|
||||||
technique: 'Diversified',
|
technique: 'Diversified',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="px-2.5 py-1.5 bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 rounded text-xs font-semibold transition text-left truncate shadow-2xs"
|
className="px-2.5 py-1.5 bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 rounded text-xs font-semibold transition text-left truncate shadow-2xs"
|
||||||
>
|
>
|
||||||
+ Cervical Protocol
|
⚡ Upper Cervical
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
onToggleAdjustment({
|
onToggleAdjustment({
|
||||||
vertebra: 'L4',
|
vertebra: 'L4',
|
||||||
region: 'Lumbar',
|
region: 'Lumbar',
|
||||||
@@ -330,7 +350,43 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="px-2.5 py-1.5 bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 rounded text-xs font-semibold transition text-left truncate shadow-2xs"
|
className="px-2.5 py-1.5 bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 rounded text-xs font-semibold transition text-left truncate shadow-2xs"
|
||||||
>
|
>
|
||||||
+ L4/SI Protocol
|
⚡ Lumbo-Sacral
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
onToggleAdjustment({
|
||||||
|
vertebra: 'T4',
|
||||||
|
region: 'Thoracic',
|
||||||
|
listing: 'Posterior Fixation (P)',
|
||||||
|
technique: 'Diversified',
|
||||||
|
});
|
||||||
|
onToggleAdjustment({
|
||||||
|
vertebra: 'T6',
|
||||||
|
region: 'Thoracic',
|
||||||
|
listing: 'Left Rotation (LP)',
|
||||||
|
technique: 'Diversified',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1.5 bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 rounded text-xs font-semibold transition text-left truncate shadow-2xs"
|
||||||
|
>
|
||||||
|
⚡ Mid-Thoracic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
onToggleAdjustment({
|
||||||
|
vertebra: 'Right Ilium',
|
||||||
|
region: 'Pelvis/Sacrum',
|
||||||
|
listing: 'Posterior Inferior (PI)',
|
||||||
|
technique: 'Thompson Drop',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1.5 bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 rounded text-xs font-semibold transition text-left truncate shadow-2xs"
|
||||||
|
>
|
||||||
|
⚡ SI Joint Drop
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Web Audio API Synthesizer for Clinical Tactile Audio Feedback
|
||||||
|
// Provides zero-latency, zero-asset medical UI sound effects
|
||||||
|
|
||||||
|
class ClinicalAudioManager {
|
||||||
|
private audioCtx: AudioContext | null = null;
|
||||||
|
private enabled: boolean = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Lazy init audio context on first user interaction
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem('mediusa_sound_enabled');
|
||||||
|
if (stored !== null) {
|
||||||
|
this.enabled = stored === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEnabled(): boolean {
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEnabled(val: boolean) {
|
||||||
|
this.enabled = val;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('mediusa_sound_enabled', String(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContext(): AudioContext | null {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
if (!this.audioCtx) {
|
||||||
|
const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (AudioContextClass) {
|
||||||
|
this.audioCtx = new AudioContextClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audioCtx && this.audioCtx.state === 'suspended') {
|
||||||
|
this.audioCtx.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft tactile click for spine segment selection, tabs, buttons
|
||||||
|
public playClick() {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(800, ctx.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.04);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.04);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.04);
|
||||||
|
} catch {
|
||||||
|
// Audio context might be restricted before interaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-tone harmonic chime for chart saving, Stripe payment, or sign-off
|
||||||
|
public playSuccess() {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const notes = [523.25, 659.25]; // C5 to E5
|
||||||
|
|
||||||
|
notes.forEach((freq, idx) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, now + idx * 0.08);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.09, now + idx * 0.08);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.08 + 0.25);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now + idx * 0.08);
|
||||||
|
osc.stop(now + idx * 0.08 + 0.26);
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gentle medical ping for waitlist SMS dispatch, notification
|
||||||
|
public playPing() {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(440, ctx.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(880, ctx.currentTime + 0.12);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.18);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clinicalAudio = new ClinicalAudioManager();
|
||||||
@@ -147,6 +147,16 @@ export const INITIAL_PATIENTS: Patient[] = [
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
activeMembership: 'Chiropractic Wellness Club',
|
activeMembership: 'Chiropractic Wellness Club',
|
||||||
packageCreditsRemaining: 4,
|
packageCreditsRemaining: 4,
|
||||||
|
vitals: {
|
||||||
|
bloodPressure: '118/76',
|
||||||
|
heartRate: 68,
|
||||||
|
temperature: '98.4 °F',
|
||||||
|
oxygenSat: 99,
|
||||||
|
painLevel: 5,
|
||||||
|
bmi: '23.8',
|
||||||
|
allergies: ['Penicillin', 'Latex'],
|
||||||
|
contraindications: ['Prior L4-L5 Microdiscectomy (2021)', 'Avoid aggressive lumbo-pelvic torsion'],
|
||||||
|
},
|
||||||
carePlan: {
|
carePlan: {
|
||||||
title: 'Lumbar Disc Decompression & Stabilization Protocol',
|
title: 'Lumbar Disc Decompression & Stabilization Protocol',
|
||||||
totalVisits: 12,
|
totalVisits: 12,
|
||||||
@@ -175,6 +185,16 @@ export const INITIAL_PATIENTS: Patient[] = [
|
|||||||
daysSinceLastVisit: 18,
|
daysSinceLastVisit: 18,
|
||||||
status: 'dropout_risk',
|
status: 'dropout_risk',
|
||||||
packageCreditsRemaining: 0,
|
packageCreditsRemaining: 0,
|
||||||
|
vitals: {
|
||||||
|
bloodPressure: '124/82',
|
||||||
|
heartRate: 74,
|
||||||
|
temperature: '98.6 °F',
|
||||||
|
oxygenSat: 98,
|
||||||
|
painLevel: 7,
|
||||||
|
bmi: '22.1',
|
||||||
|
allergies: ['Sulfa drugs'],
|
||||||
|
contraindications: ['Hypermobile C4-C5 segment', 'Prefers gentle Activator on cervical spine'],
|
||||||
|
},
|
||||||
carePlan: {
|
carePlan: {
|
||||||
title: 'Cervical Postural Realignment & Headache Relief',
|
title: 'Cervical Postural Realignment & Headache Relief',
|
||||||
totalVisits: 8,
|
totalVisits: 8,
|
||||||
@@ -203,6 +223,16 @@ export const INITIAL_PATIENTS: Patient[] = [
|
|||||||
daysSinceLastVisit: 1,
|
daysSinceLastVisit: 1,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
activeMembership: 'Athletic Recovery & Decompression Pass',
|
activeMembership: 'Athletic Recovery & Decompression Pass',
|
||||||
|
vitals: {
|
||||||
|
bloodPressure: '120/78',
|
||||||
|
heartRate: 64,
|
||||||
|
temperature: '98.2 °F',
|
||||||
|
oxygenSat: 99,
|
||||||
|
painLevel: 3,
|
||||||
|
bmi: '25.4',
|
||||||
|
allergies: [],
|
||||||
|
contraindications: ['Mild right rotator cuff impingement'],
|
||||||
|
},
|
||||||
carePlan: {
|
carePlan: {
|
||||||
title: 'Thoracic Mobility & Biomechanical Alignment',
|
title: 'Thoracic Mobility & Biomechanical Alignment',
|
||||||
totalVisits: 6,
|
totalVisits: 6,
|
||||||
@@ -230,6 +260,16 @@ export const INITIAL_PATIENTS: Patient[] = [
|
|||||||
nextAppointmentDate: '2026-09-08',
|
nextAppointmentDate: '2026-09-08',
|
||||||
daysSinceLastVisit: 4,
|
daysSinceLastVisit: 4,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
vitals: {
|
||||||
|
bloodPressure: '112/70',
|
||||||
|
heartRate: 78,
|
||||||
|
temperature: '98.7 °F',
|
||||||
|
oxygenSat: 100,
|
||||||
|
painLevel: 4,
|
||||||
|
bmi: '27.2',
|
||||||
|
allergies: ['Amoxicillin'],
|
||||||
|
contraindications: ['Pregnancy Week 32 - Webster technique prone pregnancy pillow required'],
|
||||||
|
},
|
||||||
carePlan: {
|
carePlan: {
|
||||||
title: 'Webster Technique Pelvic Balance & Comfort',
|
title: 'Webster Technique Pelvic Balance & Comfort',
|
||||||
totalVisits: 10,
|
totalVisits: 10,
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ export interface CarePlan {
|
|||||||
status: 'on_track' | 'lagging' | 'at_risk' | 'completed';
|
status: 'on_track' | 'lagging' | 'at_risk' | 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PatientVitals {
|
||||||
|
bloodPressure: string;
|
||||||
|
heartRate: number;
|
||||||
|
temperature: string;
|
||||||
|
oxygenSat: number;
|
||||||
|
painLevel: number;
|
||||||
|
bmi: string;
|
||||||
|
allergies: string[];
|
||||||
|
contraindications: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Patient {
|
export interface Patient {
|
||||||
id: string;
|
id: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@@ -66,6 +77,7 @@ export interface Patient {
|
|||||||
daysSinceLastVisit: number;
|
daysSinceLastVisit: number;
|
||||||
activeMembership?: string;
|
activeMembership?: string;
|
||||||
packageCreditsRemaining?: number;
|
packageCreditsRemaining?: number;
|
||||||
|
vitals?: PatientVitals;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppointmentStatus = 'booked' | 'confirmed' | 'arrived' | 'in_room' | 'completed' | 'no_show';
|
export type AppointmentStatus = 'booked' | 'confirmed' | 'arrived' | 'in_room' | 'completed' | 'no_show';
|
||||||
|
|||||||
Reference in New Issue
Block a user