fix(wiring): multi-tenant isolation, in-calendar quick scheduler, telehealth note sync, and superbill history
This commit is contained in:
+136
-8
@@ -349,6 +349,127 @@ export default function Home() {
|
|||||||
setActiveTenantId(tenantId);
|
setActiveTenantId(tenantId);
|
||||||
setPortalMode('clinic');
|
setPortalMode('clinic');
|
||||||
setClinicTab('calendar');
|
setClinicTab('calendar');
|
||||||
|
|
||||||
|
// Automatically sync active patient, note, and superbills for the selected clinic
|
||||||
|
const tenantPatients = patients.filter((p) => p.tenantId === tenantId);
|
||||||
|
if (tenantPatients.length > 0) {
|
||||||
|
setActivePatient(tenantPatients[0]);
|
||||||
|
const matchingSoap =
|
||||||
|
soapNotes.find((s) => s.patientId === tenantPatients[0].id) ||
|
||||||
|
soapNotes.find((s) => s.tenantId === tenantId);
|
||||||
|
setActiveSoapNote(matchingSoap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantSuperbills = superbills.filter((sb) => sb.tenantId === tenantId);
|
||||||
|
if (tenantSuperbills.length > 0) {
|
||||||
|
setActiveSuperbill(tenantSuperbills[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickScheduleAppointment = (newAptData: any) => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
const newApt: Appointment = {
|
||||||
|
id: `apt-${Date.now()}`,
|
||||||
|
...newAptData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingPatient = patients.find((p) => p.id === newAptData.patientId);
|
||||||
|
if (!existingPatient) {
|
||||||
|
const nameParts = (newAptData.patientName || 'New Patient').split(' ');
|
||||||
|
const firstName = nameParts[0] || 'Walk-In';
|
||||||
|
const lastName = nameParts.slice(1).join(' ') || 'Patient';
|
||||||
|
const newPatient: Patient = {
|
||||||
|
id: newAptData.patientId,
|
||||||
|
tenantId: activeTenant.id,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`,
|
||||||
|
phone: newAptData.patientPhone || '(555) 000-0000',
|
||||||
|
dob: '1990-01-01',
|
||||||
|
gender: 'Undisclosed',
|
||||||
|
address: activeTenant.cityStateZip,
|
||||||
|
insuranceName: 'Direct / Stripe Connect',
|
||||||
|
insuranceId: 'SELF-PAY',
|
||||||
|
chiefComplaint: newAptData.notes || newAptData.serviceType,
|
||||||
|
daysSinceLastVisit: 0,
|
||||||
|
lastVisitDate: newAptData.date,
|
||||||
|
nextAppointmentDate: newAptData.date,
|
||||||
|
status: 'active',
|
||||||
|
carePlan: {
|
||||||
|
title: 'Acute Relief & Biomechanical Alignment',
|
||||||
|
totalVisits: 6,
|
||||||
|
completedVisits: 1,
|
||||||
|
frequency: '1-2x / week',
|
||||||
|
targetCondition: newAptData.serviceType,
|
||||||
|
startDate: newAptData.date,
|
||||||
|
status: 'on_track',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setPatients((prev) => [newPatient, ...prev]);
|
||||||
|
setActivePatient(newPatient);
|
||||||
|
} else {
|
||||||
|
setActivePatient(existingPatient);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppointments((prev) => [newApt, ...prev]);
|
||||||
|
addToast(
|
||||||
|
'success',
|
||||||
|
'Encounter Scheduled',
|
||||||
|
`${newApt.patientName} scheduled for ${newApt.time} (${newApt.serviceType})`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndTelehealthCall = (consultData: {
|
||||||
|
subjective: string;
|
||||||
|
objective: string;
|
||||||
|
assessment: string;
|
||||||
|
plan: string;
|
||||||
|
duration: string;
|
||||||
|
}) => {
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
const updatedNote: SoapNote = {
|
||||||
|
id: activeSoapNote?.id || `soap-${Date.now()}`,
|
||||||
|
tenantId: activeTenant.id,
|
||||||
|
patientId: activePatient.id,
|
||||||
|
patientName: `${activePatient.firstName} ${activePatient.lastName}`,
|
||||||
|
providerId: activeProvider.id,
|
||||||
|
providerName: activeProvider.name,
|
||||||
|
date: today,
|
||||||
|
status: 'draft',
|
||||||
|
discipline: 'chiropractic',
|
||||||
|
vasScore: 4,
|
||||||
|
subjective: consultData.subjective,
|
||||||
|
objective: consultData.objective,
|
||||||
|
assessment: consultData.assessment,
|
||||||
|
plan: `${consultData.plan}\n\n[Telehealth Video Consult • Duration: ${consultData.duration} • Encrypted WebRTC Session]`,
|
||||||
|
spinalAdjustments: activeSoapNote?.spinalAdjustments || [],
|
||||||
|
icd10Codes: activeSoapNote?.icd10Codes || [
|
||||||
|
{ code: 'M99.01', description: 'Segmental and somatic dysfunction of cervical region' },
|
||||||
|
],
|
||||||
|
cptCodes: [
|
||||||
|
{ code: '99203', description: 'Office/Telehealth Outpatient Visit, 30 min', fee: 110 },
|
||||||
|
{ code: '97110', description: 'Therapeutic Exercises & Ergonomic Protocol', fee: 45 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
setSoapNotes((prev) => {
|
||||||
|
const idx = prev.findIndex((s) => s.patientId === activePatient.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const copy = [...prev];
|
||||||
|
copy[idx] = updatedNote;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
return [updatedNote, ...prev];
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveSoapNote(updatedNote);
|
||||||
|
setClinicTab('charting');
|
||||||
|
addToast(
|
||||||
|
'success',
|
||||||
|
'Telehealth Note Synced to Chart',
|
||||||
|
`Clinical findings for ${activePatient.firstName} ${activePatient.lastName} transferred to SOAP editor (${consultData.duration})`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateProductStock = (productId: string, newStock: number) => {
|
const handleUpdateProductStock = (productId: string, newStock: number) => {
|
||||||
@@ -464,7 +585,7 @@ export default function Home() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={activeTenantId}
|
value={activeTenantId}
|
||||||
onChange={(e) => setActiveTenantId(e.target.value)}
|
onChange={(e) => handleSwitchTenant(e.target.value)}
|
||||||
className="appearance-none bg-slate-50 hover:bg-slate-100 border border-slate-300 text-slate-800 text-xs font-bold rounded-lg px-3 py-1.5 pr-8 cursor-pointer focus:outline-none focus:border-sky-500 transition shadow-2xs"
|
className="appearance-none bg-slate-50 hover:bg-slate-100 border border-slate-300 text-slate-800 text-xs font-bold rounded-lg px-3 py-1.5 pr-8 cursor-pointer focus:outline-none focus:border-sky-500 transition shadow-2xs"
|
||||||
>
|
>
|
||||||
{tenants.map((t) => (
|
{tenants.map((t) => (
|
||||||
@@ -742,17 +863,23 @@ 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">
|
||||||
{portalMode === 'clinic' && (
|
{portalMode === 'clinic' && (() => {
|
||||||
|
const tenantPatients = patients.filter((p) => !p.tenantId || p.tenantId === activeTenant.id);
|
||||||
|
const tenantSuperbills = superbills.filter((s) => !s.tenantId || s.tenantId === activeTenant.id);
|
||||||
|
|
||||||
|
return (
|
||||||
<div>
|
<div>
|
||||||
{clinicTab === 'calendar' && (
|
{clinicTab === 'calendar' && (
|
||||||
<CalendarView
|
<CalendarView
|
||||||
appointments={appointments}
|
appointments={appointments}
|
||||||
providers={activeProviders}
|
providers={activeProviders}
|
||||||
activeTenant={activeTenant}
|
activeTenant={activeTenant}
|
||||||
|
patients={tenantPatients}
|
||||||
waitlistCount={waitlist.length}
|
waitlistCount={waitlist.length}
|
||||||
onSelectAppointment={handleSelectAppointment}
|
onSelectAppointment={handleSelectAppointment}
|
||||||
onUpdateStatus={handleUpdateAppointmentStatus}
|
onUpdateStatus={handleUpdateAppointmentStatus}
|
||||||
onNewAppointmentClick={() => setPortalMode('patient')}
|
onNewAppointmentClick={() => setPortalMode('patient')}
|
||||||
|
onQuickSchedule={handleQuickScheduleAppointment}
|
||||||
onOpenWaitlist={() => setIsWaitlistOpen(true)}
|
onOpenWaitlist={() => setIsWaitlistOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -774,9 +901,7 @@ export default function Home() {
|
|||||||
<TelehealthRoom
|
<TelehealthRoom
|
||||||
patient={activePatient}
|
patient={activePatient}
|
||||||
provider={activeProvider}
|
provider={activeProvider}
|
||||||
onEndCall={(noteData) => {
|
onEndCall={handleEndTelehealthCall}
|
||||||
setClinicTab('charting');
|
|
||||||
}}
|
|
||||||
onClose={() => setClinicTab('calendar')}
|
onClose={() => setClinicTab('calendar')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -793,7 +918,7 @@ export default function Home() {
|
|||||||
<MembershipsView
|
<MembershipsView
|
||||||
memberships={memberships}
|
memberships={memberships}
|
||||||
packages={packages}
|
packages={packages}
|
||||||
patients={patients}
|
patients={tenantPatients}
|
||||||
activeTenant={activeTenant}
|
activeTenant={activeTenant}
|
||||||
onEnrollPatient={handleEnrollPatientInMembership}
|
onEnrollPatient={handleEnrollPatientInMembership}
|
||||||
/>
|
/>
|
||||||
@@ -802,15 +927,17 @@ export default function Home() {
|
|||||||
{clinicTab === 'billing' && (
|
{clinicTab === 'billing' && (
|
||||||
<SuperbillView
|
<SuperbillView
|
||||||
superbill={activeSuperbill}
|
superbill={activeSuperbill}
|
||||||
|
superbills={tenantSuperbills}
|
||||||
activeTenant={activeTenant}
|
activeTenant={activeTenant}
|
||||||
onBack={() => setClinicTab('calendar')}
|
onBack={() => setClinicTab('calendar')}
|
||||||
|
onSelectSuperbill={(sb) => setActiveSuperbill(sb)}
|
||||||
onMarkPaid={handleMarkPaid}
|
onMarkPaid={handleMarkPaid}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{clinicTab === 'retention' && (
|
{clinicTab === 'retention' && (
|
||||||
<RetentionView
|
<RetentionView
|
||||||
patients={patients}
|
patients={tenantPatients}
|
||||||
activeTenant={activeTenant}
|
activeTenant={activeTenant}
|
||||||
onSelectPatientChart={(patient) => {
|
onSelectPatientChart={(patient) => {
|
||||||
setActivePatient(patient);
|
setActivePatient(patient);
|
||||||
@@ -829,7 +956,8 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{portalMode === 'patient' && (
|
{portalMode === 'patient' && (
|
||||||
<PatientPortalView
|
<PatientPortalView
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const SuperAdminView: React.FC<SuperAdminViewProps> = ({
|
|||||||
id: `tenant-${Date.now()}`,
|
id: `tenant-${Date.now()}`,
|
||||||
name: newClinicName,
|
name: newClinicName,
|
||||||
slug: newSlug,
|
slug: newSlug,
|
||||||
domain: `${newSlug}.mediusa.app`,
|
domain: `${newSlug}.mediusaos.com`,
|
||||||
phone: '(555) 839-2041',
|
phone: '(555) 839-2041',
|
||||||
email: `contact@${newSlug}.com`,
|
email: `contact@${newSlug}.com`,
|
||||||
address: '100 Wellness Way, Suite 10',
|
address: '100 Wellness Way, Suite 10',
|
||||||
|
|||||||
@@ -16,15 +16,19 @@ import {
|
|||||||
|
|
||||||
interface SuperbillViewProps {
|
interface SuperbillViewProps {
|
||||||
superbill: Superbill;
|
superbill: Superbill;
|
||||||
|
superbills?: Superbill[];
|
||||||
activeTenant: ClinicTenant;
|
activeTenant: ClinicTenant;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onSelectSuperbill?: (superbill: Superbill) => void;
|
||||||
onMarkPaid: (superbillId: string, method: Superbill['paymentMethod']) => void;
|
onMarkPaid: (superbillId: string, method: Superbill['paymentMethod']) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SuperbillView: React.FC<SuperbillViewProps> = ({
|
export const SuperbillView: React.FC<SuperbillViewProps> = ({
|
||||||
superbill,
|
superbill,
|
||||||
|
superbills = [],
|
||||||
activeTenant,
|
activeTenant,
|
||||||
onBack,
|
onBack,
|
||||||
|
onSelectSuperbill,
|
||||||
onMarkPaid,
|
onMarkPaid,
|
||||||
}) => {
|
}) => {
|
||||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
@@ -50,6 +54,46 @@ export const SuperbillView: React.FC<SuperbillViewProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Superbill Invoices & Claims Switcher Ribbon */}
|
||||||
|
{superbills.length > 1 && (
|
||||||
|
<div className="bg-white border border-slate-200/90 rounded-xl p-3 shadow-2xs flex items-center justify-between gap-3 overflow-x-auto text-xs">
|
||||||
|
<div className="flex items-center gap-2 shrink-0 font-bold text-slate-700">
|
||||||
|
<DollarSign className="w-4 h-4 text-sky-700" />
|
||||||
|
<span>Clinic Claims ({superbills.length}):</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto">
|
||||||
|
{superbills.map((sb) => {
|
||||||
|
const isSelected = sb.id === superbill.id;
|
||||||
|
const isPaid = sb.balanceDue === 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sb.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectSuperbill && onSelectSuperbill(sb)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg border text-left transition flex items-center gap-2 shrink-0 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-sky-50 border-sky-400 text-sky-950 font-bold shadow-2xs'
|
||||||
|
: 'bg-slate-50 border-slate-200 text-slate-700 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px]">{sb.invoiceNumber}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{sb.patientName}</span>
|
||||||
|
<span className="font-mono font-bold">${sb.totalAmount.toFixed(0)}</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded font-bold uppercase ${
|
||||||
|
isPaid ? 'bg-emerald-100 text-emerald-800' : 'bg-amber-100 text-amber-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPaid ? 'Paid' : 'Due'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top Header Bar */}
|
{/* Top Header Bar */}
|
||||||
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-xs flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-xs flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Appointment, ClinicTenant, Provider } from '@/types/clinical';
|
import { Appointment, ClinicTenant, Provider, Patient } from '@/types/clinical';
|
||||||
import {
|
import {
|
||||||
Calendar as CalendarIcon,
|
Calendar as CalendarIcon,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -15,16 +15,23 @@ import {
|
|||||||
Stethoscope,
|
Stethoscope,
|
||||||
Video,
|
Video,
|
||||||
Users,
|
Users,
|
||||||
|
X,
|
||||||
|
MapPin,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||||
|
|
||||||
interface CalendarViewProps {
|
interface CalendarViewProps {
|
||||||
appointments: Appointment[];
|
appointments: Appointment[];
|
||||||
providers: Provider[];
|
providers: Provider[];
|
||||||
activeTenant: ClinicTenant;
|
activeTenant: ClinicTenant;
|
||||||
|
patients?: Patient[];
|
||||||
waitlistCount?: number;
|
waitlistCount?: number;
|
||||||
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;
|
||||||
onOpenWaitlist: () => void;
|
onOpenWaitlist: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,18 +39,127 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
appointments,
|
appointments,
|
||||||
providers,
|
providers,
|
||||||
activeTenant,
|
activeTenant,
|
||||||
|
patients = [],
|
||||||
waitlistCount = 3,
|
waitlistCount = 3,
|
||||||
onSelectAppointment,
|
onSelectAppointment,
|
||||||
onUpdateStatus,
|
onUpdateStatus,
|
||||||
onNewAppointmentClick,
|
onNewAppointmentClick,
|
||||||
|
onQuickSchedule,
|
||||||
onOpenWaitlist,
|
onOpenWaitlist,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedProviderId, setSelectedProviderId] = useState<string>('all');
|
const [selectedProviderId, setSelectedProviderId] = useState<string>('all');
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date('2026-09-08T09:00:00'));
|
||||||
|
const [showAllDates, setShowAllDates] = useState(true);
|
||||||
|
|
||||||
const filteredAppointments = appointments.filter(
|
// In-calendar Quick Scheduling Modal State
|
||||||
(a) => selectedProviderId === 'all' || a.providerId === selectedProviderId
|
const [isQuickScheduleOpen, setIsQuickScheduleOpen] = useState(false);
|
||||||
|
const [patientMode, setPatientMode] = useState<'existing' | 'new'>('existing');
|
||||||
|
const [selectedPatientId, setSelectedPatientId] = useState<string>(patients[0]?.id || '');
|
||||||
|
const [newPatientName, setNewPatientName] = useState('');
|
||||||
|
const [newPatientPhone, setNewPatientPhone] = useState('');
|
||||||
|
const [scheduleProviderId, setScheduleProviderId] = useState<string>(providers[0]?.id || '');
|
||||||
|
const [scheduleService, setScheduleService] = useState('Spinal Adjustment & CMT');
|
||||||
|
const [scheduleFee, setScheduleFee] = useState(75);
|
||||||
|
const [scheduleTime, setScheduleTime] = useState('10:00 AM');
|
||||||
|
const [scheduleDuration, setScheduleDuration] = useState(20);
|
||||||
|
const [scheduleRoom, setScheduleRoom] = useState('Table 1');
|
||||||
|
const [scheduleNotes, setScheduleNotes] = useState('');
|
||||||
|
const [isTelehealthVisit, setIsTelehealthVisit] = useState(false);
|
||||||
|
|
||||||
|
// Format date display
|
||||||
|
const formattedDateString = selectedDate.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
const selectedDateIso = selectedDate.toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
// Tenant-isolated appointments
|
||||||
|
const tenantAppointments = appointments.filter(
|
||||||
|
(a) => !a.tenantId || a.tenantId === activeTenant.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredAppointments = tenantAppointments.filter((a) => {
|
||||||
|
const matchesProvider = selectedProviderId === 'all' || a.providerId === selectedProviderId;
|
||||||
|
const matchesDate = showAllDates || a.date === selectedDateIso;
|
||||||
|
return matchesProvider && matchesDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePrevDay = () => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setSelectedDate((prev) => new Date(prev.getTime() - 86400000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextDay = () => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setSelectedDate((prev) => new Date(prev.getTime() + 86400000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToday = () => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setSelectedDate(new Date('2026-09-08T09:00:00'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenScheduleModal = () => {
|
||||||
|
clinicalAudio.playClick();
|
||||||
|
setIsQuickScheduleOpen(true);
|
||||||
|
if (patients.length > 0 && !selectedPatientId) {
|
||||||
|
setSelectedPatientId(patients[0].id);
|
||||||
|
}
|
||||||
|
if (providers.length > 0 && !scheduleProviderId) {
|
||||||
|
setScheduleProviderId(providers[0].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmQuickSchedule = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clinicalAudio.playSuccess();
|
||||||
|
|
||||||
|
let targetPatientName = '';
|
||||||
|
let targetPhone = '';
|
||||||
|
let targetPatientId = selectedPatientId;
|
||||||
|
|
||||||
|
if (patientMode === 'existing') {
|
||||||
|
const p = patients.find((pat) => pat.id === selectedPatientId);
|
||||||
|
targetPatientName = p ? `${p.firstName} ${p.lastName}` : 'Walk-In Patient';
|
||||||
|
targetPhone = p?.phone || '(555) 000-0000';
|
||||||
|
} else {
|
||||||
|
targetPatientName = newPatientName.trim() || 'New Patient';
|
||||||
|
targetPhone = newPatientPhone.trim() || '(555) 123-4567';
|
||||||
|
targetPatientId = `pat-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedProvider = providers.find((prov) => prov.id === scheduleProviderId) || providers[0];
|
||||||
|
|
||||||
|
const newAptData = {
|
||||||
|
tenantId: activeTenant.id,
|
||||||
|
patientId: targetPatientId,
|
||||||
|
patientName: targetPatientName,
|
||||||
|
patientPhone: targetPhone,
|
||||||
|
providerId: assignedProvider.id,
|
||||||
|
providerName: assignedProvider.name,
|
||||||
|
date: selectedDateIso,
|
||||||
|
time: scheduleTime,
|
||||||
|
durationMinutes: scheduleDuration,
|
||||||
|
serviceType: scheduleService,
|
||||||
|
status: 'confirmed' as Appointment['status'],
|
||||||
|
room: scheduleRoom,
|
||||||
|
notes: scheduleNotes || 'Quick-scheduled via front desk calendar',
|
||||||
|
fee: scheduleFee,
|
||||||
|
isTelehealth: isTelehealthVisit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onQuickSchedule) {
|
||||||
|
onQuickSchedule(newAptData);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsQuickScheduleOpen(false);
|
||||||
|
setNewPatientName('');
|
||||||
|
setNewPatientPhone('');
|
||||||
|
setScheduleNotes('');
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: Appointment['status']) => {
|
const getStatusBadge = (status: Appointment['status']) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
@@ -90,27 +206,49 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
{/* 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">
|
||||||
{/* Date Selector */}
|
{/* Dynamic Date Selector */}
|
||||||
<div className="flex items-center bg-slate-100/90 rounded-lg p-1 border border-slate-200">
|
<div className="flex items-center bg-slate-100/90 rounded-lg p-1 border border-slate-200">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={handlePrevDay}
|
||||||
className="p-1.5 hover:bg-white rounded-md text-slate-600 hover:text-slate-900 transition"
|
className="p-1.5 hover:bg-white rounded-md text-slate-600 hover:text-slate-900 transition"
|
||||||
title="Previous Day"
|
title="Previous Day"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="px-3 text-xs font-bold text-slate-800 tracking-wide">
|
<span className="px-3 text-xs font-bold text-slate-800 tracking-wide">
|
||||||
Saturday, September 5, 2026
|
{formattedDateString}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={handleNextDay}
|
||||||
className="p-1.5 hover:bg-white rounded-md text-slate-600 hover:text-slate-900 transition"
|
className="p-1.5 hover:bg-white rounded-md text-slate-600 hover:text-slate-900 transition"
|
||||||
title="Next Day"
|
title="Next Day"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToday}
|
||||||
|
className="ml-1 px-2 py-0.5 text-[10px] font-bold text-sky-700 bg-white hover:bg-sky-50 rounded border border-slate-200 transition"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle All Dates vs Selected Day */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllDates(!showAllDates)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition border ${
|
||||||
|
showAllDates
|
||||||
|
? 'bg-sky-50 text-sky-800 border-sky-200'
|
||||||
|
: 'bg-slate-100 text-slate-600 border-slate-200 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showAllDates ? 'Showing All Encounters' : 'Filtered to Single Day'}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Attending Provider Filter Tabs */}
|
{/* Attending Provider Filter Tabs */}
|
||||||
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200 text-xs">
|
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200 text-xs">
|
||||||
<button
|
<button
|
||||||
@@ -122,22 +260,28 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
: 'text-slate-600 hover:text-slate-900'
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
All Providers
|
All Providers ({tenantAppointments.length})
|
||||||
</button>
|
</button>
|
||||||
{providers.map((p) => (
|
{providers.map((p) => {
|
||||||
|
const provCount = tenantAppointments.filter((a) => a.providerId === p.id).length;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedProviderId(p.id)}
|
onClick={() => setSelectedProviderId(p.id)}
|
||||||
className={`px-3 py-1 rounded-md font-semibold transition ${
|
className={`px-3 py-1 rounded-md font-semibold transition flex items-center gap-1.5 ${
|
||||||
selectedProviderId === p.id
|
selectedProviderId === p.id
|
||||||
? 'bg-white text-sky-800 shadow-xs border border-slate-200/80'
|
? 'bg-white text-sky-800 shadow-xs border border-slate-200/80'
|
||||||
: 'text-slate-600 hover:text-slate-900'
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.name}
|
<span>{p.name}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.2 bg-slate-200 text-slate-700 rounded-full font-mono">
|
||||||
|
{provCount}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,9 +295,10 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
Cancellation Waitlist ({waitlistCount})
|
Cancellation Waitlist ({waitlistCount})
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* In-Calendar Quick Schedule Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNewAppointmentClick}
|
onClick={handleOpenScheduleModal}
|
||||||
className="px-4 py-2 rounded-lg bg-sky-700 hover:bg-sky-800 text-white text-xs font-bold flex items-center gap-2 shadow-xs transition shrink-0"
|
className="px-4 py-2 rounded-lg bg-sky-700 hover:bg-sky-800 text-white text-xs font-bold flex items-center gap-2 shadow-xs transition shrink-0"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
@@ -163,6 +308,22 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid of Hospital Patient Encounter Cards */}
|
{/* Grid of Hospital Patient Encounter Cards */}
|
||||||
|
{filteredAppointments.length === 0 ? (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl p-12 text-center shadow-xs">
|
||||||
|
<CalendarIcon className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
||||||
|
<h4 className="text-base font-bold text-slate-800">No scheduled encounters for this view</h4>
|
||||||
|
<p className="text-xs text-slate-500 mt-1 max-w-md mx-auto">
|
||||||
|
No patient appointments found for the selected date or provider filter. Click below to quickly book an encounter.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenScheduleModal}
|
||||||
|
className="mt-4 px-4 py-2 bg-sky-700 hover:bg-sky-800 text-white text-xs font-bold rounded-lg transition inline-flex items-center gap-1.5 shadow-xs"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Quick Schedule Patient
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredAppointments.map((apt) => (
|
{filteredAppointments.map((apt) => (
|
||||||
<div
|
<div
|
||||||
@@ -260,6 +421,237 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In-Calendar Quick Schedule Modal */}
|
||||||
|
{isQuickScheduleOpen && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-xs animate-in fade-in"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-lg w-full overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
|
<div className="px-6 py-4 bg-slate-900 text-white flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-sky-600 flex items-center justify-center text-white font-black text-sm">
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm">Quick Schedule Encounter</h3>
|
||||||
|
<p className="text-[11px] text-slate-400 font-mono">
|
||||||
|
{activeTenant.name} • 2-Click Appointment Booking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsQuickScheduleOpen(false)}
|
||||||
|
className="text-slate-400 hover:text-white p-1 transition"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleConfirmQuickSchedule} className="p-6 space-y-4 overflow-y-auto text-xs">
|
||||||
|
{/* Patient Mode: Existing vs Walk-In */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 font-bold mb-1.5">Select Patient</label>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPatientMode('existing')}
|
||||||
|
className={`px-3 py-1 rounded-md font-bold transition border ${
|
||||||
|
patientMode === 'existing'
|
||||||
|
? 'bg-sky-50 text-sky-800 border-sky-300 shadow-2xs'
|
||||||
|
: 'bg-slate-50 text-slate-600 border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Existing Clinic Patient
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPatientMode('new')}
|
||||||
|
className={`px-3 py-1 rounded-md font-bold transition border ${
|
||||||
|
patientMode === 'new'
|
||||||
|
? 'bg-sky-50 text-sky-800 border-sky-300 shadow-2xs'
|
||||||
|
: 'bg-slate-50 text-slate-600 border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
+ Walk-In / New Patient
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{patientMode === 'existing' ? (
|
||||||
|
<select
|
||||||
|
value={selectedPatientId}
|
||||||
|
onChange={(e) => setSelectedPatientId(e.target.value)}
|
||||||
|
className="w-full p-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:bg-white focus:outline-none focus:border-sky-500"
|
||||||
|
>
|
||||||
|
{patients.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.firstName} {p.lastName} • {p.phone}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Full Name (e.g. Jason Miller)"
|
||||||
|
value={newPatientName}
|
||||||
|
onChange={(e) => setNewPatientName(e.target.value)}
|
||||||
|
required={patientMode === 'new'}
|
||||||
|
className="p-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 focus:bg-white focus:outline-none focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Mobile Phone (555-123-4567)"
|
||||||
|
value={newPatientPhone}
|
||||||
|
onChange={(e) => setNewPatientPhone(e.target.value)}
|
||||||
|
className="p-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 focus:bg-white focus:outline-none focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attending Provider & Operatory Room */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 font-bold mb-1">Attending Provider</label>
|
||||||
|
<select
|
||||||
|
value={scheduleProviderId}
|
||||||
|
onChange={(e) => setScheduleProviderId(e.target.value)}
|
||||||
|
className="w-full p-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:bg-white focus:outline-none focus:border-sky-500"
|
||||||
|
>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} ({p.credentials})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 font-bold mb-1">Operatory / Table</label>
|
||||||
|
<select
|
||||||
|
value={scheduleRoom}
|
||||||
|
onChange={(e) => setScheduleRoom(e.target.value)}
|
||||||
|
className="w-full p-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 font-medium focus:bg-white focus:outline-none focus:border-sky-500"
|
||||||
|
>
|
||||||
|
<option value="Table 1 (Zenith Hi-Lo)">Table 1 (Zenith Hi-Lo)</option>
|
||||||
|
<option value="Table 2 (Drop Table)">Table 2 (Drop Table)</option>
|
||||||
|
<option value="Decompression Suite">Decompression Suite</option>
|
||||||
|
<option value="Suite 2 (Pregnancy Cushion)">Suite 2 (Pregnancy Cushion)</option>
|
||||||
|
<option value="Virtual Telehealth Room">Virtual Telehealth Room</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Type & Fee */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 font-bold mb-1">Clinical Procedure / Service</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ name: 'Spinal Adjustment & CMT', fee: 75, dur: 20 },
|
||||||
|
{ name: 'Initial Examination & CMT', fee: 145, dur: 45 },
|
||||||
|
{ name: 'Triton Disc Decompression', fee: 95, dur: 30 },
|
||||||
|
{ name: 'Webster Sacral Alignment', fee: 85, dur: 30 },
|
||||||
|
].map((s) => {
|
||||||
|
const isSelected = scheduleService === s.name;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setScheduleService(s.name);
|
||||||
|
setScheduleFee(s.fee);
|
||||||
|
setScheduleDuration(s.dur);
|
||||||
|
}}
|
||||||
|
className={`p-2 rounded-lg border text-left transition ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-sky-50 border-sky-400 text-sky-900 font-bold shadow-2xs'
|
||||||
|
: 'bg-slate-50 border-slate-200 text-slate-700 hover:bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-bold truncate">{s.name}</div>
|
||||||
|
<div className="text-[10px] text-slate-500 mt-0.5">
|
||||||
|
${s.fee} • {s.dur} min
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Slots */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 font-bold mb-1">Available Time Slot</label>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{['08:30 AM', '09:00 AM', '09:45 AM', '10:30 AM', '11:15 AM', '01:15 PM', '02:00 PM', '03:30 PM'].map(
|
||||||
|
(t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScheduleTime(t)}
|
||||||
|
className={`py-1.5 px-2 rounded-lg font-mono text-center transition border ${
|
||||||
|
scheduleTime === t
|
||||||
|
? 'bg-sky-700 text-white font-bold border-sky-800 shadow-2xs'
|
||||||
|
: 'bg-slate-50 text-slate-700 border-slate-200 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telehealth toggle & Notes */}
|
||||||
|
<div className="flex items-center justify-between p-2.5 bg-purple-50 border border-purple-200 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Video className="w-4 h-4 text-purple-700" />
|
||||||
|
<span className="font-bold text-purple-900">Telehealth Video Encounter</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isTelehealthVisit}
|
||||||
|
onChange={(e) => setIsTelehealthVisit(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-purple-600 rounded border-slate-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 font-bold mb-1">Visit Notes (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Acute low back spasm after moving heavy furniture"
|
||||||
|
value={scheduleNotes}
|
||||||
|
onChange={(e) => setScheduleNotes(e.target.value)}
|
||||||
|
className="w-full p-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-800 focus:bg-white focus:outline-none focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Buttons */}
|
||||||
|
<div className="pt-3 border-t border-slate-100 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsQuickScheduleOpen(false)}
|
||||||
|
className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 font-bold rounded-lg transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2 bg-sky-700 hover:bg-sky-800 text-white font-bold rounded-lg shadow-xs transition flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
Confirm & Book Encounter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,9 +90,23 @@ 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);
|
||||||
|
|
||||||
// Restore draft from local storage if note is un-signed
|
// Sync and restore state when patient or active note changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialSoapNote && typeof window !== 'undefined') {
|
if (initialSoapNote) {
|
||||||
|
setDiscipline(initialSoapNote.discipline || 'chiropractic');
|
||||||
|
setSubjective(initialSoapNote.subjective);
|
||||||
|
setObjective(initialSoapNote.objective);
|
||||||
|
setAssessment(initialSoapNote.assessment);
|
||||||
|
setPlan(initialSoapNote.plan);
|
||||||
|
setAdjustments(initialSoapNote.spinalAdjustments || []);
|
||||||
|
setSelectedIcd10(initialSoapNote.icd10Codes || []);
|
||||||
|
setSelectedCpt(initialSoapNote.cptCodes || []);
|
||||||
|
setVasScore(initialSoapNote.vasScore || 3);
|
||||||
|
setIsSigned(initialSoapNote.status === 'signed');
|
||||||
|
setSignedTimestamp(initialSoapNote.signedAt);
|
||||||
|
} else {
|
||||||
|
let hasDraft = false;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
const key = `mediusa_draft_soap_${patient.id}`;
|
const key = `mediusa_draft_soap_${patient.id}`;
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(key);
|
||||||
@@ -104,11 +118,38 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
if (draft.plan) setPlan(draft.plan);
|
if (draft.plan) setPlan(draft.plan);
|
||||||
if (draft.vasScore !== undefined) setVasScore(draft.vasScore);
|
if (draft.vasScore !== undefined) setVasScore(draft.vasScore);
|
||||||
if (draft.adjustments && Array.isArray(draft.adjustments)) setAdjustments(draft.adjustments);
|
if (draft.adjustments && Array.isArray(draft.adjustments)) setAdjustments(draft.adjustments);
|
||||||
|
hasDraft = true;
|
||||||
setLastAutoSaved(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
setLastAutoSaved(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}, [patient.id, initialSoapNote]);
|
if (!hasDraft) {
|
||||||
|
setSubjective(
|
||||||
|
`Patient presents for scheduled visit ${patient.carePlan.completedVisits + 1} of ${
|
||||||
|
patient.carePlan.totalVisits
|
||||||
|
}. Chief complaint: ${patient.chiefComplaint}. VAS pain reported at 4/10.`
|
||||||
|
);
|
||||||
|
setObjective(
|
||||||
|
'Postural examination reveals mild anterior head carriage and unleveling of right iliac crest. Motion palpation identifies segmental hypomobility with paraspinal guarding.'
|
||||||
|
);
|
||||||
|
setAssessment(
|
||||||
|
'Segmental and somatic dysfunction of spine. Patient progressing steadily toward functional goals outlined in care plan.'
|
||||||
|
);
|
||||||
|
setPlan(
|
||||||
|
'1. High velocity low amplitude (HVLA) manipulative therapy delivered to listed subluxations.\n2. Prescribed postural home stabilization exercises.\n3. Return for scheduled care plan visit.'
|
||||||
|
);
|
||||||
|
setVasScore(4);
|
||||||
|
setAdjustments([
|
||||||
|
{ vertebra: 'L4', region: 'Lumbar', listing: 'Right Posterior (RP)', technique: 'Diversified' },
|
||||||
|
{ vertebra: 'Sacrum', region: 'Pelvis/Sacrum', listing: 'Right Sacral Base Anterior', technique: 'Thompson Drop' },
|
||||||
|
]);
|
||||||
|
setSelectedIcd10([STANDARD_ICD10_CODES[2], STANDARD_ICD10_CODES[5]]);
|
||||||
|
setSelectedCpt([STANDARD_CPT_CODES[0], STANDARD_CPT_CODES[3]]);
|
||||||
|
}
|
||||||
|
setIsSigned(false);
|
||||||
|
setSignedTimestamp(undefined);
|
||||||
|
}
|
||||||
|
}, [patient.id, initialSoapNote?.id]);
|
||||||
|
|
||||||
// Debounced auto-save draft to local storage
|
// Debounced auto-save draft to local storage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -222,6 +263,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const encounterDate = initialSoapNote?.date || new Date().toISOString().substring(0, 10);
|
||||||
const savedNote: SoapNote = {
|
const savedNote: SoapNote = {
|
||||||
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
||||||
tenantId: patient.tenantId,
|
tenantId: patient.tenantId,
|
||||||
@@ -229,7 +271,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
patientName: `${patient.firstName} ${patient.lastName}`,
|
patientName: `${patient.firstName} ${patient.lastName}`,
|
||||||
providerId: provider.id,
|
providerId: provider.id,
|
||||||
providerName: provider.name,
|
providerName: provider.name,
|
||||||
date: '2026-09-05',
|
date: encounterDate,
|
||||||
status: 'signed',
|
status: 'signed',
|
||||||
discipline,
|
discipline,
|
||||||
vasScore,
|
vasScore,
|
||||||
@@ -277,6 +319,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
|
|
||||||
const handleCreateSuperbillClick = () => {
|
const handleCreateSuperbillClick = () => {
|
||||||
clinicalAudio.playSuccess();
|
clinicalAudio.playSuccess();
|
||||||
|
const encounterDate = initialSoapNote?.date || new Date().toISOString().substring(0, 10);
|
||||||
const savedNote: SoapNote = {
|
const savedNote: SoapNote = {
|
||||||
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
||||||
tenantId: patient.tenantId,
|
tenantId: patient.tenantId,
|
||||||
@@ -284,7 +327,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
|||||||
patientName: `${patient.firstName} ${patient.lastName}`,
|
patientName: `${patient.firstName} ${patient.lastName}`,
|
||||||
providerId: provider.id,
|
providerId: provider.id,
|
||||||
providerName: provider.name,
|
providerName: provider.name,
|
||||||
date: '2026-09-05',
|
date: encounterDate,
|
||||||
status: isSigned ? 'signed' : 'draft',
|
status: isSigned ? 'signed' : 'draft',
|
||||||
discipline,
|
discipline,
|
||||||
vasScore,
|
vasScore,
|
||||||
|
|||||||
@@ -31,10 +31,18 @@ export const MembershipsView: React.FC<MembershipsViewProps> = ({
|
|||||||
activeTenant,
|
activeTenant,
|
||||||
onEnrollPatient,
|
onEnrollPatient,
|
||||||
}) => {
|
}) => {
|
||||||
|
const tenantPatients = patients.filter((p) => !p.tenantId || p.tenantId === activeTenant.id);
|
||||||
const [selectedPlanForEnroll, setSelectedPlanForEnroll] = useState<string | null>(null);
|
const [selectedPlanForEnroll, setSelectedPlanForEnroll] = useState<string | null>(null);
|
||||||
const [selectedPatientId, setSelectedPatientId] = useState<string>(patients[0]?.id || '');
|
const [selectedPatientId, setSelectedPatientId] = useState<string>(tenantPatients[0]?.id || '');
|
||||||
const [enrollSuccess, setEnrollSuccess] = useState(false);
|
const [enrollSuccess, setEnrollSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Sync selected patient if clinic changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (tenantPatients.length > 0 && !tenantPatients.some((p) => p.id === selectedPatientId)) {
|
||||||
|
setSelectedPatientId(tenantPatients[0].id);
|
||||||
|
}
|
||||||
|
}, [activeTenant.id, tenantPatients, selectedPatientId]);
|
||||||
|
|
||||||
const totalClinicMembershipMRR = memberships.reduce(
|
const totalClinicMembershipMRR = memberships.reduce(
|
||||||
(sum, m) => sum + m.mrrContribution,
|
(sum, m) => sum + m.mrrContribution,
|
||||||
0
|
0
|
||||||
@@ -238,7 +246,7 @@ export const MembershipsView: React.FC<MembershipsViewProps> = ({
|
|||||||
onChange={(e) => setSelectedPatientId(e.target.value)}
|
onChange={(e) => setSelectedPatientId(e.target.value)}
|
||||||
className="w-full bg-slate-50 border border-slate-200 rounded-lg p-3 text-slate-900 focus:bg-white focus:outline-none focus:border-sky-500"
|
className="w-full bg-slate-50 border border-slate-200 rounded-lg p-3 text-slate-900 focus:bg-white focus:outline-none focus:border-sky-500"
|
||||||
>
|
>
|
||||||
{patients.map((p) => (
|
{tenantPatients.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.firstName} {p.lastName} ({p.phone})
|
{p.firstName} {p.lastName} ({p.phone})
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -31,20 +31,21 @@ export const RetentionView: React.FC<RetentionViewProps> = ({
|
|||||||
const [sentToast, setSentToast] = useState(false);
|
const [sentToast, setSentToast] = useState(false);
|
||||||
const [filterType, setFilterType] = useState<'all' | 'dropout_risk' | 'active'>('dropout_risk');
|
const [filterType, setFilterType] = useState<'all' | 'dropout_risk' | 'active'>('dropout_risk');
|
||||||
|
|
||||||
const dropoutPatients = patients.filter((p) => p.status === 'dropout_risk');
|
const tenantPatients = patients.filter((p) => !p.tenantId || p.tenantId === activeTenant.id);
|
||||||
const activePatients = patients.filter((p) => p.status === 'active');
|
const dropoutPatients = tenantPatients.filter((p) => p.status === 'dropout_risk');
|
||||||
|
const activePatients = tenantPatients.filter((p) => p.status === 'active');
|
||||||
|
|
||||||
const displayedPatients =
|
const displayedPatients =
|
||||||
filterType === 'dropout_risk'
|
filterType === 'dropout_risk'
|
||||||
? dropoutPatients
|
? dropoutPatients
|
||||||
: filterType === 'active'
|
: filterType === 'active'
|
||||||
? activePatients
|
? activePatients
|
||||||
: patients;
|
: tenantPatients;
|
||||||
|
|
||||||
const handleOpenSmsModal = (patient: Patient) => {
|
const handleOpenSmsModal = (patient: Patient) => {
|
||||||
setSelectedPatientForSms(patient);
|
setSelectedPatientForSms(patient);
|
||||||
setCustomSmsMessage(
|
setCustomSmsMessage(
|
||||||
`Hi ${patient.firstName}! Dr. Vance noticed you're due for visit ${
|
`Hi ${patient.firstName}! Our clinical team at ${activeTenant.name} noticed you're due for visit ${
|
||||||
patient.carePlan.completedVisits + 1
|
patient.carePlan.completedVisits + 1
|
||||||
} of your ${patient.carePlan.title}. We want to ensure your recovery stays on track! Click here to pick a quick 15-min adjustment slot this week: https://${
|
} of your ${patient.carePlan.title}. We want to ensure your recovery stays on track! Click here to pick a quick 15-min adjustment slot this week: https://${
|
||||||
activeTenant.domain
|
activeTenant.domain
|
||||||
|
|||||||
@@ -576,6 +576,37 @@ export const INITIAL_SOAP_NOTES: SoapNote[] = [
|
|||||||
signedAt: '2026-09-05T09:25:00-07:00',
|
signedAt: '2026-09-05T09:25:00-07:00',
|
||||||
signedBy: 'Dr. Marcus Vance, D.C. (NPI 1942857391)',
|
signedBy: 'Dr. Marcus Vance, D.C. (NPI 1942857391)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'soap-hope-1',
|
||||||
|
tenantId: 'tenant-hope',
|
||||||
|
patientId: 'pat-hope-1',
|
||||||
|
patientName: 'Chloe Adams',
|
||||||
|
providerId: 'prov-hope-1',
|
||||||
|
providerName: 'Dr. Hope Sullivan',
|
||||||
|
appointmentId: 'apt-hope-1',
|
||||||
|
date: '2026-09-08',
|
||||||
|
status: 'signed',
|
||||||
|
discipline: 'chiropractic',
|
||||||
|
vasScore: 4,
|
||||||
|
subjective: 'Patient reports persistent upper cervical stiffness and bilateral trapezius achiness after working 50+ hours at desk. No radicular arm numbness.',
|
||||||
|
objective: 'Cervical range of motion: Right rotation 60 deg with end-range pain, Left rotation 72 deg. Segmental restriction identified at C1-C2 and C5-C6 with suboccipital hypertonicity.',
|
||||||
|
assessment: 'Segmental somatic dysfunction of cervical region (M99.01) and cervicothoracic junction with postural strain.',
|
||||||
|
plan: '1. Diversified adjustment to C1 (Right Lateral Mass) and C5.\n2. Suboccipital myofascial release.\n3. Prescribed chin-tuck postural reset exercises.',
|
||||||
|
spinalAdjustments: [
|
||||||
|
{ vertebra: 'C1 (Atlas)', region: 'Cervical', listing: 'Right Lateral Mass Anterior', technique: 'Diversified', notes: 'Gentle rotary thrust with 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: 'Chiropractic Manipulative Treatment (CMT); Spinal, 1-2 Regions', fee: 55 },
|
||||||
|
{ code: '97140', description: 'Manual Therapy Techniques / Myofascial Release (15 min)', fee: 45 },
|
||||||
|
],
|
||||||
|
signedAt: '2026-09-08T09:30:00-04:00',
|
||||||
|
signedBy: 'Dr. Hope Sullivan, D.C. (NPI 1982736450)',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INITIAL_SUPERBILLS: Superbill[] = [
|
export const INITIAL_SUPERBILLS: Superbill[] = [
|
||||||
@@ -609,6 +640,64 @@ export const INITIAL_SUPERBILLS: Superbill[] = [
|
|||||||
paymentMethod: 'Stripe Card',
|
paymentMethod: 'Stripe Card',
|
||||||
generatedAt: '2026-09-05T09:30:00-07:00',
|
generatedAt: '2026-09-05T09:30:00-07:00',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'sb-2',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
invoiceNumber: 'SB-2026-0902',
|
||||||
|
patientId: 'pat-2',
|
||||||
|
patientName: 'Sarah Holloway',
|
||||||
|
patientDob: '1993-11-20',
|
||||||
|
patientAddress: '924 Lincoln Ave, San Jose, CA 95125',
|
||||||
|
providerName: 'Dr. Marcus Vance, D.C.',
|
||||||
|
providerNpi: '1942857391',
|
||||||
|
clinicName: 'Apex Spine & Wellness Clinic',
|
||||||
|
clinicAddress: '1420 Meridian Ave, Suite 300, San Jose, CA 95125',
|
||||||
|
clinicTaxId: '84-2918471',
|
||||||
|
dateOfService: '2026-09-05',
|
||||||
|
posCode: '11 (Office)',
|
||||||
|
icd10Codes: [
|
||||||
|
{ code: 'M99.01', description: 'Segmental dysfunction cervical region' },
|
||||||
|
{ code: 'G44.209', description: 'Tension-type headache, unspecified' },
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{ cptCode: '98940', description: 'CMT Spinal, 1-2 Regions', units: 1, rate: 55, total: 55 },
|
||||||
|
{ cptCode: '97110', description: 'Therapeutic Exercise (15 min)', units: 1, rate: 40, total: 40 },
|
||||||
|
],
|
||||||
|
totalAmount: 95,
|
||||||
|
patientPaid: 0,
|
||||||
|
balanceDue: 95,
|
||||||
|
paymentMethod: 'Unpaid',
|
||||||
|
generatedAt: '2026-09-05T14:30:00-07:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sb-hope-1',
|
||||||
|
tenantId: 'tenant-hope',
|
||||||
|
invoiceNumber: 'SB-HOPE-2026-01',
|
||||||
|
patientId: 'pat-hope-1',
|
||||||
|
patientName: 'Chloe Adams',
|
||||||
|
patientDob: '1993-11-22',
|
||||||
|
patientAddress: '450 Monument Ave, Richmond, VA 23219',
|
||||||
|
providerName: 'Dr. Hope Sullivan, D.C.',
|
||||||
|
providerNpi: '1982736450',
|
||||||
|
clinicName: 'Hope Integrative Health & Chiropractic',
|
||||||
|
clinicAddress: '1250 Hope Valley Road, Suite 100, Richmond, VA 23219',
|
||||||
|
clinicTaxId: '54-9812734',
|
||||||
|
dateOfService: '2026-09-08',
|
||||||
|
posCode: '11 (Office)',
|
||||||
|
icd10Codes: [
|
||||||
|
{ code: 'M99.01', description: 'Segmental and somatic dysfunction of cervical region' },
|
||||||
|
{ code: 'M54.2', description: 'Cervicalgia / Neck Pain' },
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{ cptCode: '98940', description: 'CMT Spinal, 1-2 Regions (Cervical)', units: 1, rate: 55, total: 55 },
|
||||||
|
{ cptCode: '97140', description: 'Manual Therapy Techniques / Myofascial Release', units: 1, rate: 45, total: 45 },
|
||||||
|
],
|
||||||
|
totalAmount: 100,
|
||||||
|
patientPaid: 100,
|
||||||
|
balanceDue: 0,
|
||||||
|
paymentMethod: 'Stripe Card',
|
||||||
|
generatedAt: '2026-09-08T09:45:00-04:00',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INITIAL_PRODUCTS: RetailProduct[] = [
|
export const INITIAL_PRODUCTS: RetailProduct[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user