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 { TelehealthRoom } from '@/components/telehealth/TelehealthRoom';
|
||||
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 {
|
||||
Calendar,
|
||||
FileText,
|
||||
@@ -51,6 +54,10 @@ import {
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
UserCheck,
|
||||
Search,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
@@ -78,6 +85,11 @@ export default function Home() {
|
||||
const [waitlist, setWaitlist] = useState<WaitlistEntry[]>(INITIAL_WAITLIST);
|
||||
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 [activeSoapNote, setActiveSoapNote] = useState<SoapNote | undefined>(INITIAL_SOAP_NOTES[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 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) => {
|
||||
clinicalAudio.playClick();
|
||||
const patientMatch = patients.find((p) => p.id === apt.patientId) || patients[0];
|
||||
setActivePatient(patientMatch);
|
||||
|
||||
@@ -101,12 +158,15 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleUpdateAppointmentStatus = (aptId: string, status: Appointment['status']) => {
|
||||
clinicalAudio.playClick();
|
||||
setAppointments((prev) =>
|
||||
prev.map((a) => (a.id === aptId ? { ...a, status } : a))
|
||||
);
|
||||
addToast('info', 'Encounter Status Updated', `Appointment transitioned to ${status}`);
|
||||
};
|
||||
|
||||
const handleSaveSoapNote = (note: SoapNote) => {
|
||||
clinicalAudio.playSuccess();
|
||||
setSoapNotes((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === note.id);
|
||||
if (idx >= 0) {
|
||||
@@ -117,9 +177,11 @@ export default function Home() {
|
||||
return [...prev, note];
|
||||
});
|
||||
setActiveSoapNote(note);
|
||||
addToast('success', 'Chart Synced to HIPAA Vault', `${note.patientName} • ${note.status === 'signed' ? 'Signed & Locked' : 'Draft Saved'}`);
|
||||
};
|
||||
|
||||
const handleGenerateSuperbillFromNote = (note: SoapNote) => {
|
||||
clinicalAudio.playSuccess();
|
||||
const newSb: Superbill = {
|
||||
id: `sb-${Date.now()}`,
|
||||
tenantId: activeTenant.id,
|
||||
@@ -153,9 +215,11 @@ export default function Home() {
|
||||
setSuperbills((prev) => [newSb, ...prev]);
|
||||
setActiveSuperbill(newSb);
|
||||
setClinicTab('billing');
|
||||
addToast('success', 'Superbill Claim Created', `${newSb.invoiceNumber} • $${newSb.totalAmount.toFixed(2)} Fee Schedule`);
|
||||
};
|
||||
|
||||
const handleMarkPaid = (superbillId: string, method: Superbill['paymentMethod']) => {
|
||||
clinicalAudio.playSuccess();
|
||||
setSuperbills((prev) =>
|
||||
prev.map((sb) =>
|
||||
sb.id === superbillId
|
||||
@@ -171,9 +235,11 @@ export default function Home() {
|
||||
paymentMethod: method,
|
||||
}));
|
||||
}
|
||||
addToast('success', 'Stripe Payment Captured', `Receipt processed via ${method}`);
|
||||
};
|
||||
|
||||
const handlePatientBookingComplete = (newAptData: any) => {
|
||||
clinicalAudio.playSuccess();
|
||||
const newApt: Appointment = {
|
||||
id: `apt-${Date.now()}`,
|
||||
tenantId: activeTenant.id,
|
||||
@@ -181,34 +247,44 @@ export default function Home() {
|
||||
...newAptData,
|
||||
};
|
||||
setAppointments((prev) => [newApt, ...prev]);
|
||||
addToast('success', 'Intake & Booking Confirmed', `${newApt.patientName} scheduled for ${newApt.time}`);
|
||||
};
|
||||
|
||||
const handleAddTenant = (newTenant: ClinicTenant) => {
|
||||
setTenants((prev) => [...prev, newTenant]);
|
||||
addToast('success', 'New Clinic Deployed', `${newTenant.name} onboarded to Mediusa OS`);
|
||||
};
|
||||
|
||||
const handleSwitchTenant = (tenantId: string) => {
|
||||
clinicalAudio.playClick();
|
||||
setActiveTenantId(tenantId);
|
||||
setPortalMode('clinic');
|
||||
setClinicTab('calendar');
|
||||
};
|
||||
|
||||
const handleUpdateProductStock = (productId: string, newStock: number) => {
|
||||
clinicalAudio.playClick();
|
||||
setProducts((prev) =>
|
||||
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) => {
|
||||
clinicalAudio.playSuccess();
|
||||
setPatients((prev) =>
|
||||
prev.map((p) => (p.id === patientId ? { ...p, activeMembership: planName } : p))
|
||||
);
|
||||
addToast('success', 'Membership Enrolled', `Patient subscribed to ${planName}`);
|
||||
};
|
||||
|
||||
const handleAutoFillWaitlistPatient = (entryId: string) => {
|
||||
clinicalAudio.playPing();
|
||||
const entry = waitlist.find((w) => w.id === entryId);
|
||||
setWaitlist((prev) =>
|
||||
prev.map((w) => (w.id === entryId ? { ...w, status: 'notified' } : w))
|
||||
);
|
||||
addToast('alert', 'Waitlist SMS Dispatched', `Slot opening sent to ${entry?.patientName || 'patient'}`);
|
||||
};
|
||||
|
||||
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">
|
||||
<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">
|
||||
CLINICAL v2.4
|
||||
CLINICAL v2.5
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="relative">
|
||||
<select
|
||||
@@ -257,7 +363,11 @@ export default function Home() {
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 px-1.5">Role:</span>
|
||||
<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 ${
|
||||
staffRole === 'doctor'
|
||||
? 'bg-white text-sky-800 shadow-2xs border border-slate-200'
|
||||
@@ -270,10 +380,12 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clinicalAudio.playClick();
|
||||
setStaffRole('front_desk');
|
||||
if (clinicTab === 'charting' || clinicTab === 'telehealth') {
|
||||
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 ${
|
||||
staffRole === 'front_desk'
|
||||
@@ -286,7 +398,11 @@ export default function Home() {
|
||||
</button>
|
||||
<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 ${
|
||||
staffRole === 'billing_admin'
|
||||
? '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">
|
||||
<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 ${
|
||||
portalMode === 'clinic'
|
||||
? 'bg-sky-700 text-white font-bold shadow-xs'
|
||||
@@ -316,7 +435,10 @@ export default function Home() {
|
||||
|
||||
<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 ${
|
||||
portalMode === 'patient'
|
||||
? 'bg-sky-700 text-white font-bold shadow-xs'
|
||||
@@ -555,6 +677,34 @@ export default function Home() {
|
||||
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 */}
|
||||
<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">
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from '@/types/clinical';
|
||||
import { SpineVisualizer } from '@/components/ui/SpineVisualizer';
|
||||
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 {
|
||||
CheckCircle,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
ShieldCheck,
|
||||
Video,
|
||||
Layers,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SoapChartEditorProps {
|
||||
@@ -109,6 +112,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
};
|
||||
|
||||
const handleSwitchDiscipline = (newDiscipline: ClinicalDiscipline) => {
|
||||
clinicalAudio.playClick();
|
||||
setDiscipline(newDiscipline);
|
||||
const preset = DISCIPLINE_PRESETS[newDiscipline];
|
||||
if (preset) {
|
||||
@@ -126,6 +130,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
};
|
||||
|
||||
const handleToggleAdjustment = (entry: SpinalAdjustmentEntry) => {
|
||||
clinicalAudio.playClick();
|
||||
setAdjustments((prev) => {
|
||||
const exists = prev.some((a) => a.vertebra === entry.vertebra);
|
||||
if (exists) {
|
||||
@@ -137,6 +142,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
};
|
||||
|
||||
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.');
|
||||
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.');
|
||||
@@ -144,6 +150,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
};
|
||||
|
||||
const handleSignChart = () => {
|
||||
clinicalAudio.playSuccess();
|
||||
const now = new Date().toISOString();
|
||||
setIsSigned(true);
|
||||
setSignedTimestamp(now);
|
||||
@@ -174,6 +181,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
};
|
||||
|
||||
const handleCreateSuperbillClick = () => {
|
||||
clinicalAudio.playSuccess();
|
||||
const savedNote: SoapNote = {
|
||||
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
||||
tenantId: patient.tenantId,
|
||||
@@ -193,14 +201,18 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
icd10Codes: selectedIcd10,
|
||||
cptCodes: selectedCpt,
|
||||
signedAt: signedTimestamp,
|
||||
signedBy: `${provider.name}, ${provider.credentials}`,
|
||||
signedBy: isSigned ? `${provider.name}, ${provider.credentials}` : undefined,
|
||||
};
|
||||
|
||||
onSaveSoapNote(savedNote);
|
||||
onGenerateSuperbill(savedNote);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Patient Clinical HUD Ribbon */}
|
||||
<PatientClinicalHud patient={patient} />
|
||||
|
||||
{/* 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="flex items-center gap-3">
|
||||
@@ -218,14 +230,10 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
<h3 className="text-lg font-bold text-slate-900">
|
||||
{patient.firstName} {patient.lastName}
|
||||
</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">
|
||||
Care Plan: Visit {patient.carePlan.completedVisits + 1} of {patient.carePlan.totalVisits}
|
||||
</span>
|
||||
{patient.activeMembership && (
|
||||
<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>
|
||||
)}
|
||||
<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">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>HIPAA Vault Sync</span>
|
||||
</div>
|
||||
{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">
|
||||
<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 { SpinalAdjustmentEntry } from '@/types/clinical';
|
||||
import { Check, X, Zap, Activity } from 'lucide-react';
|
||||
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||
|
||||
interface SpineVisualizerProps {
|
||||
adjustments: SpinalAdjustmentEntry[];
|
||||
@@ -93,6 +94,7 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
||||
|
||||
const handleOpenModal = (v: VertebraDef) => {
|
||||
if (readOnly) return;
|
||||
clinicalAudio.playClick();
|
||||
const existing = getAdjustment(v.id);
|
||||
if (existing) {
|
||||
setActiveListing(existing.listing);
|
||||
@@ -106,6 +108,7 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
||||
|
||||
const handleSaveListing = () => {
|
||||
if (!selectedVertebra) return;
|
||||
clinicalAudio.playSuccess();
|
||||
onToggleAdjustment({
|
||||
vertebra: selectedVertebra.id,
|
||||
region: selectedVertebra.region,
|
||||
@@ -117,6 +120,7 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
||||
};
|
||||
|
||||
const handleRemoveListing = (vertebraId: string) => {
|
||||
clinicalAudio.playClick();
|
||||
const existing = getAdjustment(vertebraId);
|
||||
if (existing) {
|
||||
onToggleAdjustment(existing);
|
||||
@@ -288,13 +292,28 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
||||
{/* Quick Subluxation Preset Macros */}
|
||||
{!readOnly && (
|
||||
<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">
|
||||
Clinical Fast Macros
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-slate-600 uppercase tracking-wider">
|
||||
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 className="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clinicalAudio.playSuccess();
|
||||
onToggleAdjustment({
|
||||
vertebra: 'C1',
|
||||
region: 'Cervical',
|
||||
@@ -302,19 +321,20 @@ export const SpineVisualizer: React.FC<SpineVisualizerProps> = ({
|
||||
technique: 'Diversified',
|
||||
});
|
||||
onToggleAdjustment({
|
||||
vertebra: 'C5',
|
||||
vertebra: 'C2',
|
||||
region: 'Cervical',
|
||||
listing: 'Bilateral Facet Fixation',
|
||||
listing: 'Right Rotation Fixation',
|
||||
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"
|
||||
>
|
||||
+ Cervical Protocol
|
||||
⚡ Upper Cervical
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clinicalAudio.playSuccess();
|
||||
onToggleAdjustment({
|
||||
vertebra: 'L4',
|
||||
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"
|
||||
>
|
||||
+ 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>
|
||||
</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',
|
||||
activeMembership: 'Chiropractic Wellness Club',
|
||||
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: {
|
||||
title: 'Lumbar Disc Decompression & Stabilization Protocol',
|
||||
totalVisits: 12,
|
||||
@@ -175,6 +185,16 @@ export const INITIAL_PATIENTS: Patient[] = [
|
||||
daysSinceLastVisit: 18,
|
||||
status: 'dropout_risk',
|
||||
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: {
|
||||
title: 'Cervical Postural Realignment & Headache Relief',
|
||||
totalVisits: 8,
|
||||
@@ -203,6 +223,16 @@ export const INITIAL_PATIENTS: Patient[] = [
|
||||
daysSinceLastVisit: 1,
|
||||
status: 'active',
|
||||
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: {
|
||||
title: 'Thoracic Mobility & Biomechanical Alignment',
|
||||
totalVisits: 6,
|
||||
@@ -230,6 +260,16 @@ export const INITIAL_PATIENTS: Patient[] = [
|
||||
nextAppointmentDate: '2026-09-08',
|
||||
daysSinceLastVisit: 4,
|
||||
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: {
|
||||
title: 'Webster Technique Pelvic Balance & Comfort',
|
||||
totalVisits: 10,
|
||||
|
||||
@@ -46,6 +46,17 @@ export interface CarePlan {
|
||||
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 {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -66,6 +77,7 @@ export interface Patient {
|
||||
daysSinceLastVisit: number;
|
||||
activeMembership?: string;
|
||||
packageCreditsRemaining?: number;
|
||||
vitals?: PatientVitals;
|
||||
}
|
||||
|
||||
export type AppointmentStatus = 'booked' | 'confirmed' | 'arrived' | 'in_room' | 'completed' | 'no_show';
|
||||
|
||||
Reference in New Issue
Block a user