fix(wiring): multi-tenant isolation, in-calendar quick scheduler, telehealth note sync, and superbill history
This commit is contained in:
+210
-82
@@ -349,6 +349,127 @@ export default function Home() {
|
||||
setActiveTenantId(tenantId);
|
||||
setPortalMode('clinic');
|
||||
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) => {
|
||||
@@ -464,7 +585,7 @@ export default function Home() {
|
||||
<div className="relative">
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{tenants.map((t) => (
|
||||
@@ -742,94 +863,101 @@ export default function Home() {
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
|
||||
{portalMode === 'clinic' && (
|
||||
<div>
|
||||
{clinicTab === 'calendar' && (
|
||||
<CalendarView
|
||||
appointments={appointments}
|
||||
providers={activeProviders}
|
||||
activeTenant={activeTenant}
|
||||
waitlistCount={waitlist.length}
|
||||
onSelectAppointment={handleSelectAppointment}
|
||||
onUpdateStatus={handleUpdateAppointmentStatus}
|
||||
onNewAppointmentClick={() => setPortalMode('patient')}
|
||||
onOpenWaitlist={() => setIsWaitlistOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{portalMode === 'clinic' && (() => {
|
||||
const tenantPatients = patients.filter((p) => !p.tenantId || p.tenantId === activeTenant.id);
|
||||
const tenantSuperbills = superbills.filter((s) => !s.tenantId || s.tenantId === activeTenant.id);
|
||||
|
||||
{clinicTab === 'charting' && staffRole !== 'front_desk' && (
|
||||
<SoapChartEditor
|
||||
patient={activePatient}
|
||||
provider={activeProvider}
|
||||
initialSoapNote={activeSoapNote}
|
||||
onSaveSoapNote={handleSaveSoapNote}
|
||||
onGenerateSuperbill={handleGenerateSuperbillFromNote}
|
||||
onLaunchTelehealth={() => setClinicTab('telehealth')}
|
||||
onBackToCalendar={() => setClinicTab('calendar')}
|
||||
onOpenCourtVault={() => setIsCourtVaultOpen(true)}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div>
|
||||
{clinicTab === 'calendar' && (
|
||||
<CalendarView
|
||||
appointments={appointments}
|
||||
providers={activeProviders}
|
||||
activeTenant={activeTenant}
|
||||
patients={tenantPatients}
|
||||
waitlistCount={waitlist.length}
|
||||
onSelectAppointment={handleSelectAppointment}
|
||||
onUpdateStatus={handleUpdateAppointmentStatus}
|
||||
onNewAppointmentClick={() => setPortalMode('patient')}
|
||||
onQuickSchedule={handleQuickScheduleAppointment}
|
||||
onOpenWaitlist={() => setIsWaitlistOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clinicTab === 'telehealth' && staffRole !== 'front_desk' && (
|
||||
<TelehealthRoom
|
||||
patient={activePatient}
|
||||
provider={activeProvider}
|
||||
onEndCall={(noteData) => {
|
||||
setClinicTab('charting');
|
||||
}}
|
||||
onClose={() => setClinicTab('calendar')}
|
||||
/>
|
||||
)}
|
||||
{clinicTab === 'charting' && staffRole !== 'front_desk' && (
|
||||
<SoapChartEditor
|
||||
patient={activePatient}
|
||||
provider={activeProvider}
|
||||
initialSoapNote={activeSoapNote}
|
||||
onSaveSoapNote={handleSaveSoapNote}
|
||||
onGenerateSuperbill={handleGenerateSuperbillFromNote}
|
||||
onLaunchTelehealth={() => setClinicTab('telehealth')}
|
||||
onBackToCalendar={() => setClinicTab('calendar')}
|
||||
onOpenCourtVault={() => setIsCourtVaultOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clinicTab === 'retail' && (
|
||||
<RetailInventoryView
|
||||
products={products}
|
||||
activeTenant={activeTenant}
|
||||
onUpdateStock={handleUpdateProductStock}
|
||||
/>
|
||||
)}
|
||||
{clinicTab === 'telehealth' && staffRole !== 'front_desk' && (
|
||||
<TelehealthRoom
|
||||
patient={activePatient}
|
||||
provider={activeProvider}
|
||||
onEndCall={handleEndTelehealthCall}
|
||||
onClose={() => setClinicTab('calendar')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clinicTab === 'memberships' && (
|
||||
<MembershipsView
|
||||
memberships={memberships}
|
||||
packages={packages}
|
||||
patients={patients}
|
||||
activeTenant={activeTenant}
|
||||
onEnrollPatient={handleEnrollPatientInMembership}
|
||||
/>
|
||||
)}
|
||||
{clinicTab === 'retail' && (
|
||||
<RetailInventoryView
|
||||
products={products}
|
||||
activeTenant={activeTenant}
|
||||
onUpdateStock={handleUpdateProductStock}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clinicTab === 'billing' && (
|
||||
<SuperbillView
|
||||
superbill={activeSuperbill}
|
||||
activeTenant={activeTenant}
|
||||
onBack={() => setClinicTab('calendar')}
|
||||
onMarkPaid={handleMarkPaid}
|
||||
/>
|
||||
)}
|
||||
{clinicTab === 'memberships' && (
|
||||
<MembershipsView
|
||||
memberships={memberships}
|
||||
packages={packages}
|
||||
patients={tenantPatients}
|
||||
activeTenant={activeTenant}
|
||||
onEnrollPatient={handleEnrollPatientInMembership}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clinicTab === 'retention' && (
|
||||
<RetentionView
|
||||
patients={patients}
|
||||
activeTenant={activeTenant}
|
||||
onSelectPatientChart={(patient) => {
|
||||
setActivePatient(patient);
|
||||
const existing = soapNotes.find((s) => s.patientId === patient.id);
|
||||
setActiveSoapNote(existing);
|
||||
setClinicTab('charting');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{clinicTab === 'billing' && (
|
||||
<SuperbillView
|
||||
superbill={activeSuperbill}
|
||||
superbills={tenantSuperbills}
|
||||
activeTenant={activeTenant}
|
||||
onBack={() => setClinicTab('calendar')}
|
||||
onSelectSuperbill={(sb) => setActiveSuperbill(sb)}
|
||||
onMarkPaid={handleMarkPaid}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clinicTab === 'growth' && (
|
||||
<ClinicGrowthView
|
||||
activeTenant={activeTenant}
|
||||
activeProvider={activeProvider}
|
||||
onOpenBookingPortal={() => setPortalMode('patient')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{clinicTab === 'retention' && (
|
||||
<RetentionView
|
||||
patients={tenantPatients}
|
||||
activeTenant={activeTenant}
|
||||
onSelectPatientChart={(patient) => {
|
||||
setActivePatient(patient);
|
||||
const existing = soapNotes.find((s) => s.patientId === patient.id);
|
||||
setActiveSoapNote(existing);
|
||||
setClinicTab('charting');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clinicTab === 'growth' && (
|
||||
<ClinicGrowthView
|
||||
activeTenant={activeTenant}
|
||||
activeProvider={activeProvider}
|
||||
onOpenBookingPortal={() => setPortalMode('patient')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{portalMode === 'patient' && (
|
||||
<PatientPortalView
|
||||
|
||||
@@ -57,7 +57,7 @@ export const SuperAdminView: React.FC<SuperAdminViewProps> = ({
|
||||
id: `tenant-${Date.now()}`,
|
||||
name: newClinicName,
|
||||
slug: newSlug,
|
||||
domain: `${newSlug}.mediusa.app`,
|
||||
domain: `${newSlug}.mediusaos.com`,
|
||||
phone: '(555) 839-2041',
|
||||
email: `contact@${newSlug}.com`,
|
||||
address: '100 Wellness Way, Suite 10',
|
||||
|
||||
@@ -16,15 +16,19 @@ import {
|
||||
|
||||
interface SuperbillViewProps {
|
||||
superbill: Superbill;
|
||||
superbills?: Superbill[];
|
||||
activeTenant: ClinicTenant;
|
||||
onBack: () => void;
|
||||
onSelectSuperbill?: (superbill: Superbill) => void;
|
||||
onMarkPaid: (superbillId: string, method: Superbill['paymentMethod']) => void;
|
||||
}
|
||||
|
||||
export const SuperbillView: React.FC<SuperbillViewProps> = ({
|
||||
superbill,
|
||||
superbills = [],
|
||||
activeTenant,
|
||||
onBack,
|
||||
onSelectSuperbill,
|
||||
onMarkPaid,
|
||||
}) => {
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
@@ -50,6 +54,46 @@ export const SuperbillView: React.FC<SuperbillViewProps> = ({
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Appointment, ClinicTenant, Provider } from '@/types/clinical';
|
||||
import { Appointment, ClinicTenant, Provider, Patient } from '@/types/clinical';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
@@ -15,16 +15,23 @@ import {
|
||||
Stethoscope,
|
||||
Video,
|
||||
Users,
|
||||
X,
|
||||
MapPin,
|
||||
Check,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { clinicalAudio } from '@/lib/clinical-audio';
|
||||
|
||||
interface CalendarViewProps {
|
||||
appointments: Appointment[];
|
||||
providers: Provider[];
|
||||
activeTenant: ClinicTenant;
|
||||
patients?: Patient[];
|
||||
waitlistCount?: number;
|
||||
onSelectAppointment: (apt: Appointment) => void;
|
||||
onUpdateStatus: (aptId: string, status: Appointment['status']) => void;
|
||||
onNewAppointmentClick: () => void;
|
||||
onQuickSchedule?: (newApt: any) => void;
|
||||
onOpenWaitlist: () => void;
|
||||
}
|
||||
|
||||
@@ -32,18 +39,127 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
appointments,
|
||||
providers,
|
||||
activeTenant,
|
||||
patients = [],
|
||||
waitlistCount = 3,
|
||||
onSelectAppointment,
|
||||
onUpdateStatus,
|
||||
onNewAppointmentClick,
|
||||
onQuickSchedule,
|
||||
onOpenWaitlist,
|
||||
}) => {
|
||||
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(
|
||||
(a) => selectedProviderId === 'all' || a.providerId === selectedProviderId
|
||||
// In-calendar Quick Scheduling Modal State
|
||||
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']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
@@ -90,27 +206,49 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
{/* 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-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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevDay}
|
||||
className="p-1.5 hover:bg-white rounded-md text-slate-600 hover:text-slate-900 transition"
|
||||
title="Previous Day"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-3 text-xs font-bold text-slate-800 tracking-wide">
|
||||
Saturday, September 5, 2026
|
||||
{formattedDateString}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextDay}
|
||||
className="p-1.5 hover:bg-white rounded-md text-slate-600 hover:text-slate-900 transition"
|
||||
title="Next Day"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200 text-xs">
|
||||
<button
|
||||
@@ -122,22 +260,28 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
All Providers
|
||||
All Providers ({tenantAppointments.length})
|
||||
</button>
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProviderId(p.id)}
|
||||
className={`px-3 py-1 rounded-md font-semibold transition ${
|
||||
selectedProviderId === p.id
|
||||
? 'bg-white text-sky-800 shadow-xs border border-slate-200/80'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
{providers.map((p) => {
|
||||
const provCount = tenantAppointments.filter((a) => a.providerId === p.id).length;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProviderId(p.id)}
|
||||
className={`px-3 py-1 rounded-md font-semibold transition flex items-center gap-1.5 ${
|
||||
selectedProviderId === p.id
|
||||
? 'bg-white text-sky-800 shadow-xs border border-slate-200/80'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,9 +295,10 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
Cancellation Waitlist ({waitlistCount})
|
||||
</button>
|
||||
|
||||
{/* In-Calendar Quick Schedule Button */}
|
||||
<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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -163,103 +308,350 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Grid of Hospital Patient Encounter Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredAppointments.map((apt) => (
|
||||
<div
|
||||
key={apt.id}
|
||||
className="bg-white border border-slate-200/90 hover:border-sky-300 rounded-xl p-4 shadow-xs hover:shadow-md transition-all duration-200 flex flex-col justify-between"
|
||||
{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"
|
||||
>
|
||||
<div>
|
||||
{/* Encounter Time & Clinical Status */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded-md bg-sky-50 text-sky-800 font-mono text-xs font-bold border border-sky-100">
|
||||
<Clock className="w-3.5 h-3.5 inline mr-1 text-sky-600" />
|
||||
{apt.time}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500 font-medium">
|
||||
{apt.durationMinutes} min
|
||||
</span>
|
||||
{apt.isTelehealth && (
|
||||
<span className="px-2 py-0.5 rounded bg-purple-50 text-purple-700 text-[10px] font-bold border border-purple-200 flex items-center gap-1">
|
||||
<Video className="w-3 h-3 text-purple-600" /> Telehealth
|
||||
<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">
|
||||
{filteredAppointments.map((apt) => (
|
||||
<div
|
||||
key={apt.id}
|
||||
className="bg-white border border-slate-200/90 hover:border-sky-300 rounded-xl p-4 shadow-xs hover:shadow-md transition-all duration-200 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
{/* Encounter Time & Clinical Status */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded-md bg-sky-50 text-sky-800 font-mono text-xs font-bold border border-sky-100">
|
||||
<Clock className="w-3.5 h-3.5 inline mr-1 text-sky-600" />
|
||||
{apt.time}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500 font-medium">
|
||||
{apt.durationMinutes} min
|
||||
</span>
|
||||
{apt.isTelehealth && (
|
||||
<span className="px-2 py-0.5 rounded bg-purple-50 text-purple-700 text-[10px] font-bold border border-purple-200 flex items-center gap-1">
|
||||
<Video className="w-3 h-3 text-purple-600" /> Telehealth
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge(apt.status)}
|
||||
</div>
|
||||
|
||||
{/* Patient & Examination Room */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold text-slate-900 group-hover:text-sky-700 transition">
|
||||
{apt.patientName}
|
||||
</h4>
|
||||
<span className="text-[11px] font-mono font-semibold text-slate-600 bg-slate-50 px-2 py-0.5 rounded border border-slate-200">
|
||||
${apt.fee}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-sky-700 font-medium mt-0.5">
|
||||
{apt.serviceType}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 mt-2">
|
||||
<span>Attending: {apt.providerName}</span>
|
||||
<span>•</span>
|
||||
<span className="font-medium text-slate-700">{apt.room}</span>
|
||||
</div>
|
||||
|
||||
{apt.notes && (
|
||||
<div className="mt-2.5 p-2 bg-slate-50 rounded-lg border border-slate-200 text-xs text-slate-700 italic">
|
||||
"{apt.notes}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge(apt.status)}
|
||||
</div>
|
||||
|
||||
{/* Patient & Examination Room */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold text-slate-900 group-hover:text-sky-700 transition">
|
||||
{apt.patientName}
|
||||
</h4>
|
||||
<span className="text-[11px] font-mono font-semibold text-slate-600 bg-slate-50 px-2 py-0.5 rounded border border-slate-200">
|
||||
${apt.fee}
|
||||
</span>
|
||||
{/* Clinical Action Bar */}
|
||||
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectAppointment(apt)}
|
||||
className="px-3 py-1.5 rounded-lg bg-sky-50 hover:bg-sky-100 text-sky-800 text-xs font-bold border border-sky-200 transition flex items-center gap-1.5"
|
||||
>
|
||||
<Stethoscope className="w-3.5 h-3.5 text-sky-700" />
|
||||
Open Clinical Chart
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{apt.status === 'booked' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateStatus(apt.id, 'arrived')}
|
||||
className="px-2.5 py-1 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-medium border border-slate-200"
|
||||
>
|
||||
Check In
|
||||
</button>
|
||||
)}
|
||||
{apt.status === 'arrived' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateStatus(apt.id, 'in_room')}
|
||||
className="px-2.5 py-1 rounded-md bg-amber-100 hover:bg-amber-200 text-amber-900 text-xs font-bold border border-amber-300"
|
||||
>
|
||||
Place in Room
|
||||
</button>
|
||||
)}
|
||||
{apt.status === 'in_room' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateStatus(apt.id, 'completed')}
|
||||
className="px-2.5 py-1 rounded-md bg-emerald-100 hover:bg-emerald-200 text-emerald-900 text-xs font-bold border border-emerald-300"
|
||||
>
|
||||
Mark Done
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-sky-700 font-medium mt-0.5">
|
||||
{apt.serviceType}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 mt-2">
|
||||
<span>Attending: {apt.providerName}</span>
|
||||
<span>•</span>
|
||||
<span className="font-medium text-slate-700">{apt.room}</span>
|
||||
</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>
|
||||
|
||||
{apt.notes && (
|
||||
<div className="mt-2.5 p-2 bg-slate-50 rounded-lg border border-slate-200 text-xs text-slate-700 italic">
|
||||
"{apt.notes}"
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Clinical Action Bar */}
|
||||
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectAppointment(apt)}
|
||||
className="px-3 py-1.5 rounded-lg bg-sky-50 hover:bg-sky-100 text-sky-800 text-xs font-bold border border-sky-200 transition flex items-center gap-1.5"
|
||||
>
|
||||
<Stethoscope className="w-3.5 h-3.5 text-sky-700" />
|
||||
Open Clinical Chart
|
||||
</button>
|
||||
{/* 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 className="flex items-center gap-1">
|
||||
{apt.status === 'booked' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateStatus(apt.id, 'arrived')}
|
||||
className="px-2.5 py-1 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-medium border border-slate-200"
|
||||
<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"
|
||||
>
|
||||
Check In
|
||||
</button>
|
||||
)}
|
||||
{apt.status === 'arrived' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateStatus(apt.id, 'in_room')}
|
||||
className="px-2.5 py-1 rounded-md bg-amber-100 hover:bg-amber-200 text-amber-900 text-xs font-bold border border-amber-300"
|
||||
>
|
||||
Place in Room
|
||||
</button>
|
||||
)}
|
||||
{apt.status === 'in_room' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateStatus(apt.id, 'completed')}
|
||||
className="px-2.5 py-1 rounded-md bg-emerald-100 hover:bg-emerald-200 text-emerald-900 text-xs font-bold border border-emerald-300"
|
||||
>
|
||||
Mark Done
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
</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,25 +90,66 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
const [signedTimestamp, setSignedTimestamp] = useState<string | undefined>(initialSoapNote?.signedAt);
|
||||
const [lastAutoSaved, setLastAutoSaved] = useState<string | null>(null);
|
||||
|
||||
// Restore draft from local storage if note is un-signed
|
||||
// Sync and restore state when patient or active note changes
|
||||
useEffect(() => {
|
||||
if (!initialSoapNote && typeof window !== 'undefined') {
|
||||
try {
|
||||
const key = `mediusa_draft_soap_${patient.id}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const draft = JSON.parse(raw);
|
||||
if (draft.subjective) setSubjective(draft.subjective);
|
||||
if (draft.objective) setObjective(draft.objective);
|
||||
if (draft.assessment) setAssessment(draft.assessment);
|
||||
if (draft.plan) setPlan(draft.plan);
|
||||
if (draft.vasScore !== undefined) setVasScore(draft.vasScore);
|
||||
if (draft.adjustments && Array.isArray(draft.adjustments)) setAdjustments(draft.adjustments);
|
||||
setLastAutoSaved(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
}
|
||||
} catch {}
|
||||
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 {
|
||||
const key = `mediusa_draft_soap_${patient.id}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const draft = JSON.parse(raw);
|
||||
if (draft.subjective) setSubjective(draft.subjective);
|
||||
if (draft.objective) setObjective(draft.objective);
|
||||
if (draft.assessment) setAssessment(draft.assessment);
|
||||
if (draft.plan) setPlan(draft.plan);
|
||||
if (draft.vasScore !== undefined) setVasScore(draft.vasScore);
|
||||
if (draft.adjustments && Array.isArray(draft.adjustments)) setAdjustments(draft.adjustments);
|
||||
hasDraft = true;
|
||||
setLastAutoSaved(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
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]);
|
||||
}, [patient.id, initialSoapNote?.id]);
|
||||
|
||||
// Debounced auto-save draft to local storage
|
||||
useEffect(() => {
|
||||
@@ -222,6 +263,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const encounterDate = initialSoapNote?.date || new Date().toISOString().substring(0, 10);
|
||||
const savedNote: SoapNote = {
|
||||
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
||||
tenantId: patient.tenantId,
|
||||
@@ -229,7 +271,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
patientName: `${patient.firstName} ${patient.lastName}`,
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
date: '2026-09-05',
|
||||
date: encounterDate,
|
||||
status: 'signed',
|
||||
discipline,
|
||||
vasScore,
|
||||
@@ -277,6 +319,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
|
||||
const handleCreateSuperbillClick = () => {
|
||||
clinicalAudio.playSuccess();
|
||||
const encounterDate = initialSoapNote?.date || new Date().toISOString().substring(0, 10);
|
||||
const savedNote: SoapNote = {
|
||||
id: initialSoapNote?.id || `soap-${Date.now()}`,
|
||||
tenantId: patient.tenantId,
|
||||
@@ -284,7 +327,7 @@ export const SoapChartEditor: React.FC<SoapChartEditorProps> = ({
|
||||
patientName: `${patient.firstName} ${patient.lastName}`,
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
date: '2026-09-05',
|
||||
date: encounterDate,
|
||||
status: isSigned ? 'signed' : 'draft',
|
||||
discipline,
|
||||
vasScore,
|
||||
|
||||
@@ -31,10 +31,18 @@ export const MembershipsView: React.FC<MembershipsViewProps> = ({
|
||||
activeTenant,
|
||||
onEnrollPatient,
|
||||
}) => {
|
||||
const tenantPatients = patients.filter((p) => !p.tenantId || p.tenantId === activeTenant.id);
|
||||
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);
|
||||
|
||||
// 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(
|
||||
(sum, m) => sum + m.mrrContribution,
|
||||
0
|
||||
@@ -238,7 +246,7 @@ export const MembershipsView: React.FC<MembershipsViewProps> = ({
|
||||
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"
|
||||
>
|
||||
{patients.map((p) => (
|
||||
{tenantPatients.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.firstName} {p.lastName} ({p.phone})
|
||||
</option>
|
||||
|
||||
@@ -31,20 +31,21 @@ export const RetentionView: React.FC<RetentionViewProps> = ({
|
||||
const [sentToast, setSentToast] = useState(false);
|
||||
const [filterType, setFilterType] = useState<'all' | 'dropout_risk' | 'active'>('dropout_risk');
|
||||
|
||||
const dropoutPatients = patients.filter((p) => p.status === 'dropout_risk');
|
||||
const activePatients = patients.filter((p) => p.status === 'active');
|
||||
const tenantPatients = patients.filter((p) => !p.tenantId || p.tenantId === activeTenant.id);
|
||||
const dropoutPatients = tenantPatients.filter((p) => p.status === 'dropout_risk');
|
||||
const activePatients = tenantPatients.filter((p) => p.status === 'active');
|
||||
|
||||
const displayedPatients =
|
||||
filterType === 'dropout_risk'
|
||||
? dropoutPatients
|
||||
: filterType === 'active'
|
||||
? activePatients
|
||||
: patients;
|
||||
: tenantPatients;
|
||||
|
||||
const handleOpenSmsModal = (patient: Patient) => {
|
||||
setSelectedPatientForSms(patient);
|
||||
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
|
||||
} 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
|
||||
|
||||
@@ -576,6 +576,37 @@ export const INITIAL_SOAP_NOTES: SoapNote[] = [
|
||||
signedAt: '2026-09-05T09:25:00-07:00',
|
||||
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[] = [
|
||||
@@ -609,6 +640,64 @@ export const INITIAL_SUPERBILLS: Superbill[] = [
|
||||
paymentMethod: 'Stripe Card',
|
||||
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[] = [
|
||||
|
||||
Reference in New Issue
Block a user