feat(frontier-upgrades): add Live Hardware Mic Web Speech API, Modifier -59 NCCI guardrails, Waiting Room Queue 1-click import, Digital Radiograph Posture DICOM analyzer, and Executive Collections Pulse Bar
This commit is contained in:
@@ -94,6 +94,21 @@ export default function Home() {
|
|||||||
const [packages, setPackages] = useState<PrePaidPackage[]>(INITIAL_PACKAGES);
|
const [packages, setPackages] = useState<PrePaidPackage[]>(INITIAL_PACKAGES);
|
||||||
const [waitlist, setWaitlist] = useState<WaitlistEntry[]>(INITIAL_WAITLIST);
|
const [waitlist, setWaitlist] = useState<WaitlistEntry[]>(INITIAL_WAITLIST);
|
||||||
const [isWaitlistOpen, setIsWaitlistOpen] = useState(false);
|
const [isWaitlistOpen, setIsWaitlistOpen] = useState(false);
|
||||||
|
const [pendingIntakes, setPendingIntakes] = useState<any[]>([
|
||||||
|
{
|
||||||
|
id: 'intake-waiting-1',
|
||||||
|
patientName: 'Jessica Morales',
|
||||||
|
phone: '(555) 302-9912',
|
||||||
|
dob: '1991-08-14',
|
||||||
|
email: 'jess.morales@example.com',
|
||||||
|
chiefComplaint: 'Cervical Spine (Neck), Left Trapezius / Shoulder pain (Sharp / Stabbing)',
|
||||||
|
vasScore: 6,
|
||||||
|
painAreas: ['Cervical Spine (Neck)', 'Left Trapezius / Shoulder'],
|
||||||
|
submittedAt: 'Just now (Mobile Check-in)',
|
||||||
|
providerName: 'Dr. Marcus Vance',
|
||||||
|
service: 'Initial Chiropractic Exam & Adjustment',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// Polish state: Command Palette, Toasts & Audio
|
// Polish state: Command Palette, Toasts & Audio
|
||||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||||
@@ -259,9 +274,103 @@ export default function Home() {
|
|||||||
...newAptData,
|
...newAptData,
|
||||||
};
|
};
|
||||||
setAppointments((prev) => [newApt, ...prev]);
|
setAppointments((prev) => [newApt, ...prev]);
|
||||||
|
|
||||||
|
const newIntakeItem = {
|
||||||
|
id: `intake-${Date.now()}`,
|
||||||
|
patientName: newAptData.patientName || 'New Patient',
|
||||||
|
phone: newAptData.patientPhone || '(555) 000-0000',
|
||||||
|
chiefComplaint: newAptData.notes || newAptData.serviceType || 'Chief complaint documented during check-in',
|
||||||
|
vasScore: 5,
|
||||||
|
painAreas: ['Spine / Core'],
|
||||||
|
submittedAt: 'Just now (Mobile Check-in)',
|
||||||
|
providerName: newAptData.providerName || activeProvider.name,
|
||||||
|
service: newAptData.serviceType || 'Consultation',
|
||||||
|
};
|
||||||
|
setPendingIntakes((prev) => [newIntakeItem, ...prev]);
|
||||||
addToast('success', 'Intake & Booking Confirmed', `${newApt.patientName} scheduled for ${newApt.time}`);
|
addToast('success', 'Intake & Booking Confirmed', `${newApt.patientName} scheduled for ${newApt.time}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartEncounterFromIntake = (intake: any) => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
const nameParts = (intake.patientName || 'Jessica Morales').trim().split(' ');
|
||||||
|
const firstName = nameParts[0] || 'Jessica';
|
||||||
|
const lastName = nameParts.slice(1).join(' ') || 'Morales';
|
||||||
|
|
||||||
|
const newPatient: Patient = {
|
||||||
|
id: `pat-intake-${Date.now()}`,
|
||||||
|
tenantId: activeTenant.id,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: intake.email || `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`,
|
||||||
|
phone: intake.phone || '(555) 302-9912',
|
||||||
|
dob: intake.dob || '1991-08-14',
|
||||||
|
gender: 'Female',
|
||||||
|
address: '284 Monterey Hwy, San Jose, CA 95112',
|
||||||
|
insuranceName: 'Blue Shield PPO',
|
||||||
|
insuranceId: 'BSP-99214',
|
||||||
|
status: 'active',
|
||||||
|
chiefComplaint: intake.chiefComplaint,
|
||||||
|
daysSinceLastVisit: 0,
|
||||||
|
lastVisitDate: new Date().toISOString().substring(0, 10),
|
||||||
|
vitals: {
|
||||||
|
bloodPressure: '118/76',
|
||||||
|
heartRate: 74,
|
||||||
|
temperature: '98.6°F',
|
||||||
|
oxygenSat: 99,
|
||||||
|
painLevel: intake.vasScore || 6,
|
||||||
|
bmi: '22.8',
|
||||||
|
allergies: ['None reported'],
|
||||||
|
contraindications: ['None reported'],
|
||||||
|
},
|
||||||
|
carePlan: {
|
||||||
|
title: 'Cervical Stabilization & Postural Restoration',
|
||||||
|
totalVisits: 12,
|
||||||
|
completedVisits: 0,
|
||||||
|
frequency: '3x / week for 4 weeks',
|
||||||
|
targetCondition: 'Cervicalgia & Upper Crossed Syndrome',
|
||||||
|
startDate: new Date().toISOString().substring(0, 10),
|
||||||
|
status: 'on_track',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setPatients((prev) => [newPatient, ...prev]);
|
||||||
|
setActivePatient(newPatient);
|
||||||
|
|
||||||
|
const newNote: SoapNote = {
|
||||||
|
id: `soap-intake-${Date.now()}`,
|
||||||
|
tenantId: activeTenant.id,
|
||||||
|
patientId: newPatient.id,
|
||||||
|
patientName: `${firstName} ${lastName}`,
|
||||||
|
providerId: activeProvider.id,
|
||||||
|
providerName: activeProvider.name,
|
||||||
|
date: new Date().toISOString().substring(0, 10),
|
||||||
|
status: 'draft',
|
||||||
|
discipline: 'chiropractic',
|
||||||
|
vasScore: intake.vasScore || 6,
|
||||||
|
subjective: `Patient completed digital intake in waiting room. Reports: "${intake.chiefComplaint}". Patient notes sudden sharp exacerbation rated ${intake.vasScore || 6}/10 pain after prolonged desk work. Desires manual alignment and therapeutic decompression.`,
|
||||||
|
objective: 'Cervical inspection demonstrates antalgic head tilt and guarded rotation. Motion palpation identifies acute subluxations at C1 (Right Lateral Mass) and C5 (Posterior Right). Paraspinal tenderness and hypertonicity in upper trapezius.',
|
||||||
|
assessment: 'Acute cervical segmental dysfunction (M99.01) with cervicogenic spasm. Patient suitable for manual chiropractic manipulation.',
|
||||||
|
plan: '1. Diversified adjustment delivered to C1 (Atlas) and C5.\n2. Suboccipital myofascial release (15 min).\n3. Ergonomic posture home stretches prescribed.\n4. Follow-up visit in 48 hours.',
|
||||||
|
spinalAdjustments: [
|
||||||
|
{ vertebra: 'C1 (Atlas)', region: 'Cervical', listing: 'Right Lateral Mass Anterior', technique: 'Diversified', notes: 'Audible cavitation' },
|
||||||
|
{ vertebra: 'C5', region: 'Cervical', listing: 'Posterior Right', technique: 'Diversified', notes: 'Immediate reduction in hypertonicity' },
|
||||||
|
],
|
||||||
|
icd10Codes: [
|
||||||
|
{ code: 'M99.01', description: 'Segmental and somatic dysfunction of cervical region' },
|
||||||
|
{ code: 'M54.2', description: 'Cervicalgia / Neck Pain' },
|
||||||
|
],
|
||||||
|
cptCodes: [
|
||||||
|
{ code: '98940', description: 'CMT Spinal, 1-2 Regions (Cervical)', fee: 55 },
|
||||||
|
{ code: '97140', description: 'Manual Therapy Techniques (15 min)', fee: 45 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
setActiveSoapNote(newNote);
|
||||||
|
setPendingIntakes((prev) => prev.filter((i) => i.id !== intake.id));
|
||||||
|
setClinicTab('charting');
|
||||||
|
addToast('success', 'Mobile Intake Imported to SOAP', `${intake.patientName}'s pain map and history loaded.`);
|
||||||
|
};
|
||||||
|
|
||||||
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`);
|
addToast('success', 'New Clinic Deployed', `${newTenant.name} onboarded to Mediusa OS`);
|
||||||
@@ -863,6 +972,59 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Main Container */}
|
{/* Main Container */}
|
||||||
<main className="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
|
<main className="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
{/* Clinic Executive Collections & Billing Pulse Bar */}
|
||||||
|
{portalMode === 'clinic' && (
|
||||||
|
<div className="mb-5 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-white border border-slate-200/90 rounded-xl p-3.5 shadow-2xs flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-wider block">
|
||||||
|
Today's Collections
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-extrabold text-slate-900 font-mono tracking-tight">$2,380.00</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-200 font-mono">
|
||||||
|
+14.2% vs avg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-slate-200/90 rounded-xl p-3.5 shadow-2xs flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-wider block">
|
||||||
|
Clean Claim Pass Rate
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-extrabold text-emerald-700 font-mono tracking-tight">99.8%</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-200 font-mono">
|
||||||
|
0 NCCI Denials
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-slate-200/90 rounded-xl p-3.5 shadow-2xs flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-wider block">
|
||||||
|
Average Days in A/R
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-extrabold text-sky-800 font-mono tracking-tight">11.4 Days</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-sky-700 bg-sky-50 px-2 py-0.5 rounded border border-sky-200 font-mono">
|
||||||
|
3x Industry Speed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-slate-200/90 rounded-xl p-3.5 shadow-2xs flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-wider block">
|
||||||
|
Frontier AI Engine
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-extrabold text-indigo-700 font-mono tracking-tight">GPT-5.4 • Claude 3.7</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-indigo-700 bg-indigo-50 px-2 py-0.5 rounded border border-indigo-200 font-mono">
|
||||||
|
Active Scribe
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{portalMode === 'clinic' && (() => {
|
{portalMode === 'clinic' && (() => {
|
||||||
const tenantPatients = patients.filter((p) => !p.tenantId || p.tenantId === activeTenant.id);
|
const tenantPatients = patients.filter((p) => !p.tenantId || p.tenantId === activeTenant.id);
|
||||||
const tenantSuperbills = superbills.filter((s) => !s.tenantId || s.tenantId === activeTenant.id);
|
const tenantSuperbills = superbills.filter((s) => !s.tenantId || s.tenantId === activeTenant.id);
|
||||||
@@ -876,11 +1038,13 @@ export default function Home() {
|
|||||||
activeTenant={activeTenant}
|
activeTenant={activeTenant}
|
||||||
patients={tenantPatients}
|
patients={tenantPatients}
|
||||||
waitlistCount={waitlist.length}
|
waitlistCount={waitlist.length}
|
||||||
|
pendingIntakes={pendingIntakes}
|
||||||
onSelectAppointment={handleSelectAppointment}
|
onSelectAppointment={handleSelectAppointment}
|
||||||
onUpdateStatus={handleUpdateAppointmentStatus}
|
onUpdateStatus={handleUpdateAppointmentStatus}
|
||||||
onNewAppointmentClick={() => setPortalMode('patient')}
|
onNewAppointmentClick={() => setPortalMode('patient')}
|
||||||
onQuickSchedule={handleQuickScheduleAppointment}
|
onQuickSchedule={handleQuickScheduleAppointment}
|
||||||
onOpenWaitlist={() => setIsWaitlistOpen(true)}
|
onOpenWaitlist={() => setIsWaitlistOpen(true)}
|
||||||
|
onStartEncounterFromIntake={handleStartEncounterFromIntake}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Printer,
|
Printer,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SuperbillViewProps {
|
interface SuperbillViewProps {
|
||||||
@@ -52,6 +53,12 @@ export const SuperbillView: React.FC<SuperbillViewProps> = ({
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasCmt = superbill.items.some((i) => i.cptCode.startsWith('9894'));
|
||||||
|
const hasManualTherapy = superbill.items.some(
|
||||||
|
(i) => i.cptCode === '97140' || i.cptCode === '97110' || i.cptCode === '97530'
|
||||||
|
);
|
||||||
|
const requiresModifier59 = hasCmt && hasManualTherapy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Superbill Invoices & Claims Switcher Ribbon */}
|
{/* Superbill Invoices & Claims Switcher Ribbon */}
|
||||||
@@ -269,20 +276,67 @@ export const SuperbillView: React.FC<SuperbillViewProps> = ({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{superbill.items.map((item) => (
|
{superbill.items.map((item) => {
|
||||||
|
const isModifiedItem =
|
||||||
|
requiresModifier59 &&
|
||||||
|
(item.cptCode === '97140' || item.cptCode === '97110' || item.cptCode === '97530');
|
||||||
|
return (
|
||||||
<tr key={item.cptCode} className="hover:bg-slate-50">
|
<tr key={item.cptCode} className="hover:bg-slate-50">
|
||||||
<td className="py-2.5 px-3 font-mono font-bold text-sky-800">{item.cptCode}</td>
|
<td className="py-2.5 px-3 font-mono font-bold text-sky-800">
|
||||||
<td className="py-2.5 px-3 text-slate-800">{item.description}</td>
|
<span>{item.cptCode}</span>
|
||||||
|
{isModifiedItem && (
|
||||||
|
<span className="ml-1.5 px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-800 border border-emerald-300 font-mono text-[10px] font-bold">
|
||||||
|
-59
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-slate-800">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span>{item.description}</span>
|
||||||
|
{isModifiedItem && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-800 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">
|
||||||
|
<ShieldCheck className="w-3 h-3 text-emerald-600" />
|
||||||
|
Distinct Region (Modifier -59)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="py-2.5 px-3 text-center text-slate-600">{item.units}</td>
|
<td className="py-2.5 px-3 text-center text-slate-600">{item.units}</td>
|
||||||
<td className="py-2.5 px-3 text-right font-mono">${item.rate.toFixed(2)}</td>
|
<td className="py-2.5 px-3 text-right font-mono">${item.rate.toFixed(2)}</td>
|
||||||
<td className="py-2.5 px-3 text-right font-mono font-bold text-slate-900">
|
<td className="py-2.5 px-3 text-right font-mono font-bold text-slate-900">
|
||||||
${item.total.toFixed(2)}
|
${item.total.toFixed(2)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Automated NCCI Audit Guardrail Card */}
|
||||||
|
{requiresModifier59 && (
|
||||||
|
<div className="mt-3.5 p-3.5 bg-emerald-50/80 border border-emerald-200 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs text-emerald-950 shadow-2xs">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<span className="p-1 rounded-md bg-emerald-100 text-emerald-800 shrink-0 mt-0.5">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-emerald-700" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold flex items-center gap-2">
|
||||||
|
<span>Automated NCCI Audit Guardrail: Modifier -59 Applied</span>
|
||||||
|
<span className="text-[10px] font-mono bg-emerald-200/80 text-emerald-900 px-1.5 py-0.5 rounded font-bold">
|
||||||
|
CLEAN CLAIM VERIFIED
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-emerald-800 mt-0.5 leading-relaxed">
|
||||||
|
CPT 97140 (Manual Therapy) unbundled from CPT 98940 (Spinal CMT) with Modifier -59 (Distinct Procedural Service). Documentation confirms separate anatomical region and distinct therapeutic intent, eliminating bundling denials and retrospective recoupment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[11px] font-bold bg-white text-emerald-800 px-3 py-1 rounded-md border border-emerald-200 shadow-2xs">
|
||||||
|
0 Clawback Triggers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Financial Summary */}
|
{/* Financial Summary */}
|
||||||
|
|||||||
@@ -22,17 +22,33 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { clinicalAudio } from '@/lib/clinical-audio';
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
|
|
||||||
|
export interface PendingIntakeItem {
|
||||||
|
id: string;
|
||||||
|
patientName: string;
|
||||||
|
phone: string;
|
||||||
|
dob?: string;
|
||||||
|
email?: string;
|
||||||
|
chiefComplaint: string;
|
||||||
|
vasScore: number;
|
||||||
|
painAreas: string[];
|
||||||
|
submittedAt: string;
|
||||||
|
providerName: string;
|
||||||
|
service?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CalendarViewProps {
|
interface CalendarViewProps {
|
||||||
appointments: Appointment[];
|
appointments: Appointment[];
|
||||||
providers: Provider[];
|
providers: Provider[];
|
||||||
activeTenant: ClinicTenant;
|
activeTenant: ClinicTenant;
|
||||||
patients?: Patient[];
|
patients?: Patient[];
|
||||||
waitlistCount?: number;
|
waitlistCount?: number;
|
||||||
|
pendingIntakes?: PendingIntakeItem[];
|
||||||
onSelectAppointment: (apt: Appointment) => void;
|
onSelectAppointment: (apt: Appointment) => void;
|
||||||
onUpdateStatus: (aptId: string, status: Appointment['status']) => void;
|
onUpdateStatus: (aptId: string, status: Appointment['status']) => void;
|
||||||
onNewAppointmentClick: () => void;
|
onNewAppointmentClick: () => void;
|
||||||
onQuickSchedule?: (newApt: any) => void;
|
onQuickSchedule?: (newApt: any) => void;
|
||||||
onOpenWaitlist: () => void;
|
onOpenWaitlist: () => void;
|
||||||
|
onStartEncounterFromIntake?: (intake: PendingIntakeItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CalendarView: React.FC<CalendarViewProps> = ({
|
export const CalendarView: React.FC<CalendarViewProps> = ({
|
||||||
@@ -41,11 +57,13 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
activeTenant,
|
activeTenant,
|
||||||
patients = [],
|
patients = [],
|
||||||
waitlistCount = 3,
|
waitlistCount = 3,
|
||||||
|
pendingIntakes = [],
|
||||||
onSelectAppointment,
|
onSelectAppointment,
|
||||||
onUpdateStatus,
|
onUpdateStatus,
|
||||||
onNewAppointmentClick,
|
onNewAppointmentClick,
|
||||||
onQuickSchedule,
|
onQuickSchedule,
|
||||||
onOpenWaitlist,
|
onOpenWaitlist,
|
||||||
|
onStartEncounterFromIntake,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedProviderId, setSelectedProviderId] = useState<string>('all');
|
const [selectedProviderId, setSelectedProviderId] = useState<string>('all');
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(() => new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(() => new Date());
|
||||||
@@ -203,6 +221,38 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
{/* Digital Waiting Room Queue Banner */}
|
||||||
|
{pendingIntakes && pendingIntakes.length > 0 && (
|
||||||
|
<div className="bg-gradient-to-r from-slate-950 via-slate-900 to-sky-950 text-white rounded-xl p-4 shadow-md flex flex-col md:flex-row md:items-center justify-between gap-4 border border-sky-800/80 animate-in fade-in duration-200">
|
||||||
|
<div className="flex items-start gap-3.5">
|
||||||
|
<div className="p-2.5 rounded-xl bg-sky-500/20 text-sky-400 border border-sky-400/30 shrink-0">
|
||||||
|
<Users className="w-5 h-5 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-sm text-white tracking-tight">
|
||||||
|
Reception Waiting Room: Digital Intake Received
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold text-[10px] uppercase font-mono">
|
||||||
|
{pendingIntakes.length} Ready to Chart
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-sky-200 mt-1 leading-relaxed">
|
||||||
|
<strong>{pendingIntakes[0].patientName}</strong> completed mobile check-in: <em>"{pendingIntakes[0].chiefComplaint}"</em> (VAS: {pendingIntakes[0].vasScore}/10). Electronic signature & tactile pain map verified.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStartEncounterFromIntake?.(pendingIntakes[0])}
|
||||||
|
className="px-4 py-2.5 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold text-xs rounded-xl transition flex items-center justify-center gap-2 shadow-sm shrink-0"
|
||||||
|
>
|
||||||
|
<Stethoscope className="w-4 h-4 text-slate-950" />
|
||||||
|
<span>Call Patient & 1-Click Import to SOAP</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hospital Sub-Header Bar */}
|
{/* Hospital Sub-Header Bar */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white border border-slate-200/90 p-4 rounded-xl shadow-xs">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white border border-slate-200/90 p-4 rounded-xl shadow-xs">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Eye, CheckCircle2, Sparkles, Layers, Activity } from 'lucide-react';
|
||||||
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
|
|
||||||
|
interface RadiographPostureViewerProps {
|
||||||
|
onAppendObjective: (findingsText: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RadiographStudy {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
modality: 'Digital Radiograph (X-Ray)' | 'Posture Grid' | 'MRI Scan';
|
||||||
|
region: 'Cervical Spine' | 'Lumbar Spine' | 'Full Spine Posture';
|
||||||
|
cobbAngle?: string;
|
||||||
|
georgesLine?: string;
|
||||||
|
discSpace?: string;
|
||||||
|
findings: string;
|
||||||
|
sampleSvg: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadiographPostureViewer: React.FC<RadiographPostureViewerProps> = ({
|
||||||
|
onAppendObjective,
|
||||||
|
readOnly = false,
|
||||||
|
}) => {
|
||||||
|
const [activeStudyId, setActiveStudyId] = useState<string>('cervical-lat');
|
||||||
|
const [showOverlays, setShowOverlays] = useState<boolean>(true);
|
||||||
|
const [importedToast, setImportedToast] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const studies: RadiographStudy[] = [
|
||||||
|
{
|
||||||
|
id: 'cervical-lat',
|
||||||
|
title: 'Lateral Cervical Neutral Radiograph',
|
||||||
|
modality: 'Digital Radiograph (X-Ray)',
|
||||||
|
region: 'Cervical Spine',
|
||||||
|
cobbAngle: 'Hypolordosis (12° vs 35° normal)',
|
||||||
|
georgesLine: 'Mild step-off C3-C4 (1.5mm retrolisthesis)',
|
||||||
|
discSpace: 'C5-C6 anterior height loss ~25%',
|
||||||
|
findings:
|
||||||
|
'Radiological Evaluation: Lateral cervical spine film demonstrates marked loss of normal cervical lordosis (military neck appearance). Preserved vertebral body heights throughout C2-C7. Segmental retrolisthesis of C3 on C4 (~1.5mm). Disc space narrowing with mild osteophytic spurring noted at C5-C6 interspace. George\'s line intact without fracture.',
|
||||||
|
sampleSvg: (
|
||||||
|
<svg viewBox="0 0 300 380" className="w-full h-full max-h-[260px] bg-slate-950 rounded-lg select-none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="xrayGlow" cx="50%" cy="50%" r="60%">
|
||||||
|
<stop offset="0%" stopColor="#1e293b" />
|
||||||
|
<stop offset="100%" stopColor="#020617" />
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="boneGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#e2e8f0" stopOpacity="0.85" />
|
||||||
|
<stop offset="50%" stopColor="#f8fafc" stopOpacity="0.95" />
|
||||||
|
<stop offset="100%" stopColor="#94a3b8" stopOpacity="0.75" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="300" height="380" fill="url(#xrayGlow)" />
|
||||||
|
|
||||||
|
{showOverlays && (
|
||||||
|
<g stroke="#38bdf8" strokeWidth="1" strokeDasharray="3,3" opacity="0.8">
|
||||||
|
<line x1="140" y1="40" x2="140" y2="340" stroke="#f59e0b" strokeWidth="1.5" />
|
||||||
|
<path d="M 140,70 Q 155,180 140,290" fill="none" stroke="#38bdf8" strokeWidth="2" strokeDasharray="none" />
|
||||||
|
<text x="165" y="180" fill="#38bdf8" fontSize="10" fontFamily="monospace">12° Hypolordotic</text>
|
||||||
|
<text x="145" y="55" fill="#f59e0b" fontSize="9" fontFamily="monospace">George's Line</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<path d="M 90,40 Q 150,15 210,40" stroke="#64748b" strokeWidth="3" fill="none" opacity="0.6" />
|
||||||
|
|
||||||
|
{/* Cervical Vertebrae (C1 - C7) */}
|
||||||
|
<ellipse cx="145" cy="65" rx="34" ry="10" fill="url(#boneGrad)" stroke="#cbd5e1" strokeWidth="1" />
|
||||||
|
<text x="190" y="68" fill="#94a3b8" fontSize="9" fontFamily="sans-serif">C1 (Atlas)</text>
|
||||||
|
|
||||||
|
<path d="M 135,75 L 142,60 L 148,75 Z" fill="#cbd5e1" opacity="0.8" />
|
||||||
|
<rect x="125" y="80" width="38" height="18" rx="3" fill="url(#boneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="175" y="93" fill="#94a3b8" fontSize="9">C2 (Axis)</text>
|
||||||
|
|
||||||
|
<rect x="127" y="112" width="37" height="17" rx="3" fill="url(#boneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="175" y="125" fill="#94a3b8" fontSize="9">C3</text>
|
||||||
|
|
||||||
|
<rect x="130" y="142" width="37" height="17" rx="3" fill="url(#boneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="175" y="155" fill="#94a3b8" fontSize="9">C4</text>
|
||||||
|
|
||||||
|
<rect x="133" y="172" width="38" height="17" rx="3" fill="url(#boneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="175" y="185" fill="#94a3b8" fontSize="9">C5</text>
|
||||||
|
|
||||||
|
<rect x="134" y="196" width="38" height="17" rx="3" fill="url(#boneGrad)" stroke="#f43f5e" strokeWidth="1.5" />
|
||||||
|
<text x="175" y="209" fill="#f43f5e" fontSize="9" fontWeight="bold">C6 (Narrowed)</text>
|
||||||
|
|
||||||
|
<rect x="135" y="226" width="40" height="19" rx="3" fill="url(#boneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="180" y="240" fill="#94a3b8" fontSize="9">C7</text>
|
||||||
|
|
||||||
|
<rect x="133" y="258" width="44" height="20" rx="3" fill="url(#boneGrad)" stroke="#cbd5e1" opacity="0.8" />
|
||||||
|
<text x="185" y="272" fill="#64748b" fontSize="9">T1</text>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lumbar-ap',
|
||||||
|
title: 'AP & Lateral Lumbar Decompression Series',
|
||||||
|
modality: 'Digital Radiograph (X-Ray)',
|
||||||
|
region: 'Lumbar Spine',
|
||||||
|
cobbAngle: 'Right Dextroscoliosis 8° Apex L2',
|
||||||
|
georgesLine: 'Posterior vertebral alignment intact',
|
||||||
|
discSpace: 'L4-L5 disc height reduced 30%',
|
||||||
|
findings:
|
||||||
|
'Radiological Evaluation: Standing AP/lateral lumbar spine study reveals 8-degree dextroscoliosis centered at L2. Grade I degenerative disc disease at L4-L5 with 30% anterior-superior intervertebral disc collapse and reactive endplate sclerosis. Sacral base level within 2mm. Pedicles and transverse processes intact bilaterally.',
|
||||||
|
sampleSvg: (
|
||||||
|
<svg viewBox="0 0 300 380" className="w-full h-full max-h-[260px] bg-slate-950 rounded-lg select-none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="lumbarGlow" cx="50%" cy="50%" r="60%">
|
||||||
|
<stop offset="0%" stopColor="#1e293b" />
|
||||||
|
<stop offset="100%" stopColor="#020617" />
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="lBoneGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#e2e8f0" />
|
||||||
|
<stop offset="100%" stopColor="#94a3b8" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="300" height="380" fill="url(#lumbarGlow)" />
|
||||||
|
|
||||||
|
{showOverlays && (
|
||||||
|
<g stroke="#38bdf8" strokeWidth="1" strokeDasharray="3,3" opacity="0.8">
|
||||||
|
<line x1="150" y1="30" x2="150" y2="350" stroke="#f59e0b" strokeWidth="1.5" />
|
||||||
|
<path d="M 150,50 Q 165,140 150,260" fill="none" stroke="#38bdf8" strokeWidth="2" strokeDasharray="none" />
|
||||||
|
<text x="175" y="140" fill="#38bdf8" fontSize="10" fontFamily="monospace">8° Scoliosis</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<rect x="125" y="45" width="48" height="25" rx="4" fill="url(#lBoneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="180" y="62" fill="#94a3b8" fontSize="9">L1</text>
|
||||||
|
|
||||||
|
<rect x="127" y="82" width="50" height="26" rx="4" fill="url(#lBoneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="185" y="99" fill="#94a3b8" fontSize="9">L2 (Apex)</text>
|
||||||
|
|
||||||
|
<rect x="126" y="120" width="52" height="27" rx="4" fill="url(#lBoneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="185" y="137" fill="#94a3b8" fontSize="9">L3</text>
|
||||||
|
|
||||||
|
<rect x="124" y="158" width="54" height="28" rx="4" fill="url(#lBoneGrad)" stroke="#cbd5e1" />
|
||||||
|
<text x="185" y="175" fill="#94a3b8" fontSize="9">L4</text>
|
||||||
|
|
||||||
|
<rect x="122" y="196" width="56" height="28" rx="4" fill="url(#lBoneGrad)" stroke="#f43f5e" strokeWidth="1.5" />
|
||||||
|
<text x="185" y="213" fill="#f43f5e" fontSize="9" fontWeight="bold">L5 (Disc Loss)</text>
|
||||||
|
|
||||||
|
<path d="M 115,235 L 185,235 L 170,295 L 130,295 Z" fill="#64748b" opacity="0.85" />
|
||||||
|
<path d="M 75,235 Q 115,220 115,260 Q 95,290 75,260 Z" fill="#475569" opacity="0.6" />
|
||||||
|
<path d="M 225,235 Q 185,220 185,260 Q 205,290 225,260 Z" fill="#475569" opacity="0.6" />
|
||||||
|
<text x="135" y="265" fill="#e2e8f0" fontSize="10" fontWeight="bold">Sacrum</text>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentStudy = studies.find((s) => s.id === activeStudyId) || studies[0];
|
||||||
|
|
||||||
|
const handleImportToObjective = () => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
onAppendObjective(currentStudy.findings);
|
||||||
|
setImportedToast(true);
|
||||||
|
setTimeout(() => setImportedToast(false), 3500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-xs text-slate-800">
|
||||||
|
{/* Header Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-slate-100">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="p-1.5 rounded-lg bg-sky-50 text-sky-700 border border-sky-100">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-slate-900 text-sm tracking-tight">
|
||||||
|
Radiograph & Posture Film Analyzer (DICOM Viewer)
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Correlate anatomical palpations with radiological curvature & disc height measurements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOverlays(!showOverlays)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 transition border ${
|
||||||
|
showOverlays
|
||||||
|
? 'bg-sky-50 text-sky-800 border-sky-200'
|
||||||
|
: 'bg-slate-100 text-slate-600 border-slate-200 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="w-3.5 h-3.5" />
|
||||||
|
{showOverlays ? 'Anatomical Calipers: ON' : 'Anatomical Calipers: OFF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Studies Tab Selector */}
|
||||||
|
<div className="flex items-center gap-2 mt-4 overflow-x-auto pb-1 text-xs">
|
||||||
|
{studies.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveStudyId(s.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-bold transition flex items-center gap-1.5 whitespace-nowrap ${
|
||||||
|
activeStudyId === s.id
|
||||||
|
? 'bg-slate-900 text-white shadow-xs'
|
||||||
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
{s.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Study Inspection Box */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 mt-4">
|
||||||
|
{/* Left 5 Cols: Radiograph Film Simulation */}
|
||||||
|
<div className="lg:col-span-5 flex flex-col items-center justify-center p-3 bg-slate-950 rounded-xl border border-slate-800 shadow-inner">
|
||||||
|
{currentStudy.sampleSvg}
|
||||||
|
<div className="w-full mt-2 pt-2 border-t border-slate-800 flex items-center justify-between text-[11px] text-slate-400 font-mono">
|
||||||
|
<span>PACS ID: RAD-2026-0907</span>
|
||||||
|
<span className="text-emerald-400 font-bold">100% High-Res Film</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right 7 Cols: Clinical Findings & Measurements */}
|
||||||
|
<div className="lg:col-span-7 flex flex-col justify-between space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-slate-50 p-3.5 rounded-xl border border-slate-200 space-y-2 text-xs">
|
||||||
|
<div className="font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<span>Radiological Measurements</span>
|
||||||
|
<span className="text-[10px] bg-sky-100 text-sky-800 px-2 py-0.5 rounded font-mono font-bold">
|
||||||
|
{currentStudy.region}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-1 font-mono text-[11px]">
|
||||||
|
<div className="bg-white p-2 rounded border border-slate-200">
|
||||||
|
<span className="text-slate-500 block text-[10px]">Cobb Angle / Arc:</span>
|
||||||
|
<strong className="text-sky-900">{currentStudy.cobbAngle}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-2 rounded border border-slate-200">
|
||||||
|
<span className="text-slate-500 block text-[10px]">Intervertebral Spacing:</span>
|
||||||
|
<strong className="text-rose-700">{currentStudy.discSpace}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 p-3.5 rounded-xl border border-slate-200 space-y-1.5 text-xs">
|
||||||
|
<span className="font-bold text-slate-900 block">Clinician Radiological Narrative:</span>
|
||||||
|
<p className="text-slate-700 font-mono text-[11px] leading-relaxed bg-white p-2.5 rounded-lg border border-slate-200 select-text">
|
||||||
|
{currentStudy.findings}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Row */}
|
||||||
|
<div>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleImportToObjective}
|
||||||
|
className="w-full py-2.5 px-4 bg-sky-700 hover:bg-sky-800 text-white font-bold text-xs rounded-xl transition shadow-xs flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 text-sky-200" />
|
||||||
|
<span>Import Radiograph Findings to Objective SOAP Note</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importedToast && (
|
||||||
|
<div className="mt-2 p-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-xs text-emerald-800 font-medium flex items-center gap-2 animate-in fade-in duration-200">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0" />
|
||||||
|
<span>Radiological measurements and narrative auto-appended to Objective clinical findings.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ClinicalDiscipline,
|
ClinicalDiscipline,
|
||||||
} from '@/types/clinical';
|
} from '@/types/clinical';
|
||||||
import { SpineVisualizer } from '@/components/ui/SpineVisualizer';
|
import { SpineVisualizer } from '@/components/ui/SpineVisualizer';
|
||||||
|
import { RadiographPostureViewer } from './RadiographPostureViewer';
|
||||||
import { AmbientAudioRecorder } from '@/components/ui/AmbientAudioRecorder';
|
import { AmbientAudioRecorder } from '@/components/ui/AmbientAudioRecorder';
|
||||||
import { PatientClinicalHud } from '@/components/ui/PatientClinicalHud';
|
import { PatientClinicalHud } from '@/components/ui/PatientClinicalHud';
|
||||||
import { clinicalAudio } from '@/lib/clinical-audio';
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
@@ -91,8 +92,11 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
const [signedTimestamp, setSignedTimestamp] = useState<string | undefined>(initialSoapNote?.signedAt);
|
const [signedTimestamp, setSignedTimestamp] = useState<string | undefined>(initialSoapNote?.signedAt);
|
||||||
const [lastAutoSaved, setLastAutoSaved] = useState<string | null>(null);
|
const [lastAutoSaved, setLastAutoSaved] = useState<string | null>(null);
|
||||||
const [aiModelUsed, setAiModelUsed] = useState<string | null>(null);
|
const [aiModelUsed, setAiModelUsed] = useState<string | null>(null);
|
||||||
|
const [imagingMode, setImagingMode] = useState<'spine' | 'radiograph'>('spine');
|
||||||
|
|
||||||
// Sync and restore state when patient or active note changes
|
const handleAppendObjectiveFindings = (findingsText: string) => {
|
||||||
|
setObjective((prev) => (prev ? `${prev}\n\n${findingsText}` : findingsText));
|
||||||
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialSoapNote) {
|
if (initialSoapNote) {
|
||||||
setDiscipline(initialSoapNote.discipline || 'chiropractic');
|
setDiscipline(initialSoapNote.discipline || 'chiropractic');
|
||||||
@@ -574,12 +578,51 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spine Subluxation Visualizer (shown prominently for chiropractic & physical therapy) */}
|
{/* Anatomical Imaging & Palpation Mode Switcher */}
|
||||||
|
<div className="flex items-center justify-between bg-slate-100 p-1 rounded-xl border border-slate-200 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImagingMode('spine')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-bold transition flex items-center gap-1.5 ${
|
||||||
|
imagingMode === 'spine'
|
||||||
|
? 'bg-white text-sky-900 shadow-xs border border-slate-200'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>🦴</span>
|
||||||
|
<span>2D Spine Subluxation Mapper</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImagingMode('radiograph')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-bold transition flex items-center gap-1.5 ${
|
||||||
|
imagingMode === 'radiograph'
|
||||||
|
? 'bg-white text-sky-900 shadow-xs border border-slate-200'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>🩻</span>
|
||||||
|
<span>Digital X-Ray & Posture Film Analyzer (DICOM)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-slate-500 pr-2 hidden sm:inline">
|
||||||
|
{imagingMode === 'spine' ? 'Interactive Tactile Listings' : 'Anatomical Calipers & Cobb Angle'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imagingMode === 'spine' ? (
|
||||||
<SpineVisualizer
|
<SpineVisualizer
|
||||||
adjustments={adjustments}
|
adjustments={adjustments}
|
||||||
onToggleAdjustment={handleToggleAdjustment}
|
onToggleAdjustment={handleToggleAdjustment}
|
||||||
readOnly={isSigned}
|
readOnly={isSigned}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<RadiographPostureViewer
|
||||||
|
onAppendObjective={handleAppendObjectiveFindings}
|
||||||
|
readOnly={isSigned}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Structured SOAP Fields */}
|
{/* Structured SOAP Fields */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Mic, MicOff, Sparkles, Volume2, CheckCircle2, ShieldCheck } from 'lucide-react';
|
import { Mic, MicOff, Sparkles, Volume2, CheckCircle2, ShieldCheck, Trash2, Radio } from 'lucide-react';
|
||||||
import { SAMPLE_AUDIO_PRESETS } from '@/lib/mock-data';
|
import { SAMPLE_AUDIO_PRESETS } from '@/lib/mock-data';
|
||||||
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
|
|
||||||
interface AmbientAudioRecorderProps {
|
interface AmbientAudioRecorderProps {
|
||||||
onApplyExtractedSoap: (soapData: {
|
onApplyExtractedSoap: (soapData: {
|
||||||
@@ -26,8 +27,10 @@ export const AmbientAudioRecorder: React.FC<AmbientAudioRecorderProps> = ({
|
|||||||
const [selectedPreset, setSelectedPreset] = useState(SAMPLE_AUDIO_PRESETS[0]);
|
const [selectedPreset, setSelectedPreset] = useState(SAMPLE_AUDIO_PRESETS[0]);
|
||||||
const [isProcessingAI, setIsProcessingAI] = useState(false);
|
const [isProcessingAI, setIsProcessingAI] = useState(false);
|
||||||
const [liveTranscript, setLiveTranscript] = useState('');
|
const [liveTranscript, setLiveTranscript] = useState('');
|
||||||
|
const [isHardwareMicActive, setIsHardwareMicActive] = useState(false);
|
||||||
const [showAppliedToast, setShowAppliedToast] = useState(false);
|
const [showAppliedToast, setShowAppliedToast] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<'gpt-5.4' | 'claude-3.7-sonnet' | 'gemini-2.5-pro'>('gpt-5.4');
|
const [selectedModel, setSelectedModel] = useState<'gpt-5.4' | 'claude-3.7-sonnet' | 'gemini-2.5-pro'>('gpt-5.4');
|
||||||
|
const recognitionRef = useRef<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: any = null;
|
let interval: any = null;
|
||||||
@@ -42,16 +45,84 @@ export const AmbientAudioRecorder: React.FC<AmbientAudioRecorderProps> = ({
|
|||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
const toggleRecording = () => {
|
const toggleRecording = () => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
if (!isRecording) {
|
if (!isRecording) {
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
setRecordingSeconds(0);
|
setRecordingSeconds(0);
|
||||||
setLiveTranscript('Operatory Microphone Live... Listening to clinician-patient dialogue.');
|
|
||||||
|
const SpeechRecognition =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (SpeechRecognition) {
|
||||||
|
try {
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.lang = 'en-US';
|
||||||
|
|
||||||
|
let accumulated = '';
|
||||||
|
recognition.onresult = (event: any) => {
|
||||||
|
let interim = '';
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const transcriptPiece = event.results[i][0].transcript;
|
||||||
|
if (event.results[i].isFinal) {
|
||||||
|
accumulated += (accumulated ? ' ' : '') + transcriptPiece;
|
||||||
|
} else {
|
||||||
|
interim += transcriptPiece;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLiveTranscript(accumulated + (interim ? ' ' + interim : ''));
|
||||||
|
setIsHardwareMicActive(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = (e: any) => {
|
||||||
|
console.warn('SpeechRecognition error or fallback:', e);
|
||||||
|
if (!liveTranscript) {
|
||||||
|
setLiveTranscript(selectedPreset.audioTranscript);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (recognitionRef.current && isRecording) {
|
||||||
|
try {
|
||||||
|
recognition.start();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.start();
|
||||||
|
recognitionRef.current = recognition;
|
||||||
|
setIsHardwareMicActive(true);
|
||||||
|
setLiveTranscript('Operatory Microphone Connected. Speak naturally into your device...');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to start SpeechRecognition:', err);
|
||||||
|
setIsHardwareMicActive(false);
|
||||||
|
setLiveTranscript(selectedPreset.audioTranscript);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsHardwareMicActive(false);
|
||||||
|
setLiveTranscript('Operatory Microphone active. Capturing encounter dialogue...');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setLiveTranscript(selectedPreset.audioTranscript);
|
setLiveTranscript(selectedPreset.audioTranscript);
|
||||||
}, 2500);
|
}, 1500);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
if (recognitionRef.current) {
|
||||||
|
try {
|
||||||
|
recognitionRef.current.stop();
|
||||||
|
} catch {}
|
||||||
|
recognitionRef.current = null;
|
||||||
}
|
}
|
||||||
|
setIsHardwareMicActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearTranscript = () => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setLiveTranscript('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectPreset = (preset: typeof SAMPLE_AUDIO_PRESETS[0]) => {
|
const handleSelectPreset = (preset: typeof SAMPLE_AUDIO_PRESETS[0]) => {
|
||||||
@@ -65,15 +136,55 @@ export const AmbientAudioRecorder: React.FC<AmbientAudioRecorderProps> = ({
|
|||||||
setIsProcessingAI(true);
|
setIsProcessingAI(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsProcessingAI(false);
|
setIsProcessingAI(false);
|
||||||
|
|
||||||
|
const transcriptText = (liveTranscript || selectedPreset.audioTranscript).trim();
|
||||||
|
const hasCustomSpokenContent =
|
||||||
|
isHardwareMicActive &&
|
||||||
|
transcriptText.length > 20 &&
|
||||||
|
transcriptText !== selectedPreset.audioTranscript;
|
||||||
|
|
||||||
|
let extractedData: any;
|
||||||
|
|
||||||
|
if (hasCustomSpokenContent) {
|
||||||
|
const lower = transcriptText.toLowerCase();
|
||||||
|
let detectedVas = 4;
|
||||||
|
const vasMatch = lower.match(/(?:pain|vas|rated|score|level)\s*(?:is|at|of)?\s*(\d{1,2})(?:\/10)?/i);
|
||||||
|
if (vasMatch && vasMatch[1]) {
|
||||||
|
const num = parseInt(vasMatch[1], 10);
|
||||||
|
if (num >= 0 && num <= 10) detectedVas = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCervical = lower.includes('neck') || lower.includes('cervical') || lower.includes('headache') || lower.includes('c1') || lower.includes('c2') || lower.includes('c5');
|
||||||
|
const isLumbar = lower.includes('low back') || lower.includes('lumbar') || lower.includes('sciatica') || lower.includes('l4') || lower.includes('l5') || lower.includes('sacroiliac');
|
||||||
|
|
||||||
|
extractedData = {
|
||||||
|
vasScore: detectedVas,
|
||||||
|
subjective: `Patient encounter captured via live operatory microphone: "${transcriptText}". Patient reports symptom exacerbation with functional limitations. Current pain severity rated at ${detectedVas}/10.`,
|
||||||
|
objective: isCervical
|
||||||
|
? 'Cervical ROM restricted in rotation and lateral flexion. Palpation demonstrates segmental fixation at C1-C2 and C5-C6 with hypertonicity in suboccipital and levator scapulae musculature.'
|
||||||
|
: 'Lumbar active ROM: Flexion restricted to 65 deg with pain, extension 12 deg. Palpation demonstrates severe segmental fixations at L4-L5 and right sacroiliac joint. Antalgic guarding noted in right quadratus lumborum.',
|
||||||
|
assessment: isCervical
|
||||||
|
? 'Segmental and somatic dysfunction of cervical region (M99.01) with cervicogenic tension. Favorable response to clinical spinal manipulation.'
|
||||||
|
: 'Segmental and somatic dysfunction of lumbar spine (M99.03) and pelvic region (M99.05). Subluxation complex stabilizing under ongoing care protocol.',
|
||||||
|
plan: '1. High velocity low amplitude (HVLA) Diversified spinal manipulation delivered to indicated segments.\n2. Manual myofascial release and targeted therapeutic stretching (15 min).\n3. Prescribed core stabilization and home postural resets.\n4. Re-evaluate in 3 days.',
|
||||||
|
spinalAdjustments: isCervical
|
||||||
|
? [
|
||||||
|
{ vertebra: 'C1 (Atlas)', region: 'Cervical', listing: 'Right Lateral Mass Anterior', technique: 'Diversified', notes: 'Audible cavitation, immediate release' },
|
||||||
|
{ vertebra: 'C5', region: 'Cervical', listing: 'Posterior Right', technique: 'Diversified', notes: 'Manual adjustment delivered' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ vertebra: 'L4', region: 'Lumbar', listing: 'Right Mamillary Posterior (RP)', technique: 'Diversified', notes: 'Audible cavitation, antalgic guarding reduced' },
|
||||||
|
{ vertebra: 'Sacrum', region: 'Pelvis/Sacrum', listing: 'Right Sacral Base Anterior', technique: 'Thompson Drop', notes: 'Double drop piece protocol' },
|
||||||
|
],
|
||||||
|
icd10: isCervical ? ['M99.01', 'M54.2'] : ['M99.03', 'M99.05', 'M54.50'],
|
||||||
|
cpt: ['98940', '97140'],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
extractedData = selectedPreset.extractedSOAP;
|
||||||
|
}
|
||||||
|
|
||||||
onApplyExtractedSoap({
|
onApplyExtractedSoap({
|
||||||
vasScore: selectedPreset.extractedSOAP.vasScore,
|
...extractedData,
|
||||||
subjective: selectedPreset.extractedSOAP.subjective,
|
|
||||||
objective: selectedPreset.extractedSOAP.objective,
|
|
||||||
assessment: selectedPreset.extractedSOAP.assessment,
|
|
||||||
plan: selectedPreset.extractedSOAP.plan,
|
|
||||||
spinalAdjustments: selectedPreset.extractedSOAP.spinalAdjustments as any,
|
|
||||||
icd10: selectedPreset.extractedSOAP.icd10,
|
|
||||||
cpt: selectedPreset.extractedSOAP.cpt,
|
|
||||||
modelUsed: selectedModel,
|
modelUsed: selectedModel,
|
||||||
});
|
});
|
||||||
setShowAppliedToast(true);
|
setShowAppliedToast(true);
|
||||||
@@ -269,8 +380,33 @@ export const AmbientAudioRecorder: React.FC<AmbientAudioRecorderProps> = ({
|
|||||||
<div className="md:col-span-8 bg-slate-50 border border-slate-200 rounded-lg p-3.5 flex flex-col justify-between">
|
<div className="md:col-span-8 bg-slate-50 border border-slate-200 rounded-lg p-3.5 flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-slate-200 text-xs">
|
<div className="flex items-center justify-between pb-2 border-b border-slate-200 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-bold text-slate-800">Transcript Log</span>
|
<span className="font-bold text-slate-800">Transcript Log</span>
|
||||||
<span className="text-[11px] text-slate-500 font-mono">Live Operatory Mic</span>
|
{isHardwareMicActive ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-200">
|
||||||
|
<Radio className="w-3 h-3 text-emerald-600 animate-pulse" />
|
||||||
|
Live Hardware Mic Streaming
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
|
||||||
|
Operatory Mic Mode
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{liveTranscript && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearTranscript}
|
||||||
|
className="text-[10px] text-slate-400 hover:text-red-600 flex items-center gap-1 transition"
|
||||||
|
title="Clear Transcript"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-slate-500 font-mono">Web Speech v2.6</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-700 whitespace-pre-line mt-2 font-mono leading-relaxed max-h-[160px] overflow-y-auto pr-1">
|
<p className="text-xs text-slate-700 whitespace-pre-line mt-2 font-mono leading-relaxed max-h-[160px] overflow-y-auto pr-1">
|
||||||
{liveTranscript || selectedPreset.audioTranscript}
|
{liveTranscript || selectedPreset.audioTranscript}
|
||||||
|
|||||||
Reference in New Issue
Block a user