feat: Initial Mediusa Clinic OS practice management platform ('Jane App But Better')

This commit is contained in:
2026-09-05 14:12:19 -07:00
parent 441e5eb57d
commit deb5197e53
17 changed files with 4675 additions and 71 deletions
+346
View File
@@ -0,0 +1,346 @@
'use client';
import React, { useState } from 'react';
import { Patient, ClinicTenant } from '@/types/clinical';
import {
Users,
AlertTriangle,
MessageSquare,
TrendingUp,
CheckCircle,
Clock,
Sparkles,
PhoneCall,
Calendar,
Send,
X,
} from 'lucide-react';
interface RetentionViewProps {
patients: Patient[];
activeTenant: ClinicTenant;
onSelectPatientChart: (patient: Patient) => void;
}
export const RetentionView: React.FC<RetentionViewProps> = ({
patients,
activeTenant,
onSelectPatientChart,
}) => {
const [selectedPatientForSms, setSelectedPatientForSms] = useState<Patient | null>(null);
const [customSmsMessage, setCustomSmsMessage] = useState('');
const [isSendingSms, setIsSendingSms] = useState(false);
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 displayedPatients =
filterType === 'dropout_risk'
? dropoutPatients
: filterType === 'active'
? activePatients
: patients;
const handleOpenSmsModal = (patient: Patient) => {
setSelectedPatientForSms(patient);
setCustomSmsMessage(
`Hi ${patient.firstName}! Dr. Vance noticed you're due for visit ${
patient.carePlan.completedVisits + 1
} of your ${patient.carePlan.title}. We want to make sure your progress stays on track! Click here to pick a quick 15-min adjustment slot this week: https://${
activeTenant.domain
}/book?ref=careplan`
);
};
const handleSendSms = () => {
setIsSendingSms(true);
setTimeout(() => {
setIsSendingSms(false);
setSentToast(true);
if (selectedPatientForSms) {
selectedPatientForSms.status = 'reactivated';
}
setTimeout(() => {
setSentToast(false);
setSelectedPatientForSms(null);
}, 1500);
}, 1000);
};
return (
<div className="space-y-6">
{/* Top Value Metric Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl shadow-lg">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Care Plan Retention</span>
<TrendingUp className="w-4 h-4 text-teal-400" />
</div>
<div className="text-2xl font-black text-white mt-2">84.2%</div>
<p className="text-[11px] text-teal-400 mt-1 flex items-center gap-1 font-medium">
<span>+18% higher than Jane App</span>
</p>
</div>
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl shadow-lg">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Patients at Dropout Risk</span>
<AlertTriangle className="w-4 h-4 text-amber-400" />
</div>
<div className="text-2xl font-black text-amber-400 mt-2">
{dropoutPatients.length} Patients
</div>
<p className="text-[11px] text-slate-400 mt-1">Missed expected cadence &gt; 10 days</p>
</div>
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl shadow-lg">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Revenue at Risk</span>
<span className="text-xs font-mono text-red-400 font-bold">$</span>
</div>
<div className="text-2xl font-black text-white mt-2">$2,140.00</div>
<p className="text-[11px] text-red-400 mt-1">Unbilled care plan visits</p>
</div>
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl shadow-lg">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Recovered This Month</span>
<Sparkles className="w-4 h-4 text-teal-400" />
</div>
<div className="text-2xl font-black text-emerald-400 mt-2">+$3,850.00</div>
<p className="text-[11px] text-emerald-400/80 mt-1">Via AI Autonomous SMS</p>
</div>
</div>
{/* Main Table / Patient List Header */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-5 shadow-xl">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-800">
<div>
<h3 className="font-bold text-white text-base flex items-center gap-2">
<Users className="w-4 h-4 text-teal-400" />
Care Plan Adherence &amp; Reactivation Radar
</h3>
<p className="text-xs text-slate-400 mt-0.5">
Identifies patients who have dropped off their prescribed spinal treatment plans before completing their clinical goals.
</p>
</div>
<div className="flex items-center gap-1 bg-slate-800 p-1 rounded-xl border border-slate-700 text-xs">
<button
type="button"
onClick={() => setFilterType('dropout_risk')}
className={`px-3 py-1 rounded-lg font-medium transition ${
filterType === 'dropout_risk'
? 'bg-amber-500 text-slate-950 font-bold shadow'
: 'text-slate-400 hover:text-white'
}`}
>
Dropout Risk ({dropoutPatients.length})
</button>
<button
type="button"
onClick={() => setFilterType('active')}
className={`px-3 py-1 rounded-lg font-medium transition ${
filterType === 'active'
? 'bg-teal-500 text-slate-950 font-bold shadow'
: 'text-slate-400 hover:text-white'
}`}
>
On Track ({activePatients.length})
</button>
<button
type="button"
onClick={() => setFilterType('all')}
className={`px-3 py-1 rounded-lg font-medium transition ${
filterType === 'all'
? 'bg-indigo-600 text-white font-bold shadow'
: 'text-slate-400 hover:text-white'
}`}
>
All Patients ({patients.length})
</button>
</div>
</div>
{/* Patient Rows */}
<div className="divide-y divide-slate-800 mt-2">
{displayedPatients.map((p) => {
const isAtRisk = p.status === 'dropout_risk';
const progressPct = Math.round(
(p.carePlan.completedVisits / p.carePlan.totalVisits) * 100
);
return (
<div
key={p.id}
className="py-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-slate-800/30 px-2 rounded-xl transition"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="font-bold text-white text-sm">
{p.firstName} {p.lastName}
</h4>
{isAtRisk ? (
<span className="px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[10px] font-bold uppercase flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> {p.daysSinceLastVisit} Days Since Visit
</span>
) : (
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 text-[10px] font-bold uppercase flex items-center gap-1">
<CheckCircle className="w-3 h-3" /> On Track
</span>
)}
</div>
<p className="text-xs text-slate-300 mt-1">
Care Plan: <strong>{p.carePlan.title}</strong> ({p.carePlan.frequency})
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-400">
<div className="flex items-center gap-2 w-48">
<div className="w-full bg-slate-800 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full ${
isAtRisk ? 'bg-amber-400' : 'bg-teal-400'
}`}
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="font-mono text-[11px] shrink-0">
{p.carePlan.completedVisits}/{p.carePlan.totalVisits}
</span>
</div>
<span>Phone: {p.phone}</span>
<span></span>
<span>Last Visit: {p.lastVisitDate}</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => onSelectPatientChart(p)}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold rounded-lg transition"
>
View Chart
</button>
<button
type="button"
onClick={() => handleOpenSmsModal(p)}
className={`px-3 py-1.5 text-xs font-bold rounded-lg flex items-center gap-1.5 transition ${
isAtRisk
? 'bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20'
: 'bg-teal-500/20 hover:bg-teal-500/30 text-teal-300 border border-teal-500/40'
}`}
>
<MessageSquare className="w-3.5 h-3.5" />
{isAtRisk ? 'Send AI Reactivation SMS' : 'Message Patient'}
</button>
</div>
</div>
);
})}
</div>
</div>
{/* SMS Reactivation Modal */}
{selectedPatientForSms && (
<div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-lg w-full p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-150 text-slate-100">
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
<div className="flex items-center gap-2">
<span className="p-2 rounded-xl bg-amber-500/20 text-amber-300">
<MessageSquare className="w-5 h-5" />
</span>
<div>
<h4 className="font-bold text-white text-sm">
Autonomous SMS Reactivation Dispatch
</h4>
<p className="text-[11px] text-slate-400">
To: {selectedPatientForSms.firstName} {selectedPatientForSms.lastName} ({selectedPatientForSms.phone})
</p>
</div>
</div>
<button
type="button"
onClick={() => setSelectedPatientForSms(null)}
className="text-slate-400 hover:text-white p-1"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="mt-4 space-y-3 text-xs">
<div className="bg-slate-950/80 p-3 rounded-xl border border-slate-800 text-slate-300 flex items-center justify-between">
<div>
<span className="text-slate-400">Sending Clinic Line:</span>{' '}
<strong className="text-white font-mono">{activeTenant.phone}</strong>
</div>
<span className="text-[10px] bg-teal-500/20 text-teal-300 px-2 py-0.5 rounded font-bold">
AI Pilots Gateway
</span>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
Personalized 2-Way Message (Editable)
</label>
<textarea
rows={5}
value={customSmsMessage}
onChange={(e) => setCustomSmsMessage(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-xs text-slate-200 focus:outline-none focus:border-amber-500 resize-none font-sans"
/>
</div>
{sentToast && (
<div className="p-3 bg-emerald-500/20 border border-emerald-500/40 rounded-xl text-xs text-emerald-300 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-400" />
<span>SMS Dispatched! Patient status updated to Reactivated.</span>
</div>
)}
</div>
<div className="mt-6 pt-3 border-t border-slate-800 flex items-center justify-between">
<span className="text-[11px] text-slate-500">
Auto-fills calendar slots when patient clicks.
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setSelectedPatientForSms(null)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold rounded-xl transition"
>
Cancel
</button>
<button
type="button"
disabled={isSendingSms}
onClick={handleSendSms}
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 text-xs font-bold rounded-xl shadow-lg shadow-amber-500/20 transition flex items-center gap-1.5 disabled:opacity-50"
>
{isSendingSms ? (
<>
<span className="w-3.5 h-3.5 border-2 border-slate-950 border-t-transparent rounded-full animate-spin" />
Sending SMS...
</>
) : (
<>
<Send className="w-3.5 h-3.5" />
Send Reactivation Text
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};