347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
'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 > 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 & 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>
|
|
);
|
|
};
|