- Add floating chat window Hand-off to AI toggle (per-patient) and after-hours AI toggle (global) - Add LangGraph-powered appointment reminder flow: AI introduces itself, classifies YES/NO, handles confirmation with appointment date/time - Add multi-step rescheduling flow: ASAP vs next week, tomorrow offer, Mon/Tue/Wed picker, morning/afternoon time slot — automatically updates appointment in DB - Add new patient / after-hours flow: new vs existing patient, dental insurance check, MassHealth Selenium eligibility check (auto-uses saved member ID + DOB for existing patients), self-pay fallback - Add AI Chat Settings page (Settings → Advanced) with editable greeting templates and LangGraph flow diagrams for both reminder and new-patient flows - Add Schedule a New Patient template option in chat window, starts new-patient conversation flow - Add GET/PUT endpoints for AI handoff, after-hours handoff, and AI chat templates - Add multilingual support (7 languages) across all AI reply nodes with LLM generation and hardcoded fallbacks - Add pending reschedule in-memory store and conversation stage tracking across all flows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
409 lines
14 KiB
TypeScript
Executable File
409 lines
14 KiB
TypeScript
Executable File
import { useState } from "react";
|
|
import { formatDistanceToNow, parseISO } from "date-fns";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { Sidebar } from "@/components/layout/sidebar";
|
|
import { TopAppBar } from "@/components/layout/top-app-bar";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Phone,
|
|
PhoneCall,
|
|
Clock,
|
|
User,
|
|
Search,
|
|
MessageSquare,
|
|
Send,
|
|
X,
|
|
MoonStar,
|
|
} from "lucide-react";
|
|
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
|
|
import { MessageThread } from "@/components/patient-connection/message-thread";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import type { Patient } from "@repo/db/types";
|
|
|
|
export default function PatientConnectionPage() {
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
|
const [isSmsDialogOpen, setIsSmsDialogOpen] = useState(false);
|
|
const [showMessaging, setShowMessaging] = useState(false);
|
|
const [afterHoursEnabled, setAfterHoursEnabled] = useState(true);
|
|
const { toast } = useToast();
|
|
|
|
useQuery<{ enabled: boolean }>({
|
|
queryKey: ["/api/twilio/after-hours-handoff"],
|
|
queryFn: async () => {
|
|
const res = await apiRequest("GET", "/api/twilio/after-hours-handoff");
|
|
return res.json();
|
|
},
|
|
onSuccess: (data: { enabled: boolean }) => setAfterHoursEnabled(data.enabled),
|
|
} as any);
|
|
|
|
const afterHoursMutation = useMutation({
|
|
mutationFn: async (enabled: boolean) =>
|
|
apiRequest("PUT", "/api/twilio/after-hours-handoff", { enabled }),
|
|
onSuccess: (_: any, enabled: boolean) => {
|
|
toast({
|
|
title: enabled ? "After-hours AI enabled" : "After-hours AI disabled",
|
|
description: enabled
|
|
? "AI will automatically handle messages outside office hours."
|
|
: "After-hours messages will not receive an automatic AI reply.",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleAfterHoursToggle = (enabled: boolean) => {
|
|
setAfterHoursEnabled(enabled);
|
|
afterHoursMutation.mutate(enabled);
|
|
};
|
|
|
|
const toggleMobileMenu = () => {
|
|
setIsMobileMenuOpen(!isMobileMenuOpen);
|
|
};
|
|
|
|
const makeCallMutation = useMutation({
|
|
mutationFn: async ({
|
|
to,
|
|
patientId,
|
|
}: {
|
|
to: string;
|
|
patientId: number;
|
|
}) => {
|
|
return apiRequest("POST", "/api/twilio/make-call", {
|
|
to,
|
|
message:
|
|
"Hello, this is a call from your dental office. We are calling to connect with you.",
|
|
patientId,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Call Initiated",
|
|
description: "The call has been placed successfully.",
|
|
});
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Call Failed",
|
|
description:
|
|
error.message || "Unable to place the call. Please try again.",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Fetch all patients from database
|
|
const { data: patients = [], isLoading } = useQuery<Patient[]>({
|
|
queryKey: ["/api/patients"],
|
|
});
|
|
|
|
// Filter patients based on search term
|
|
const filteredPatients = patients.filter((patient) => {
|
|
if (!searchTerm) return false;
|
|
const searchLower = searchTerm.toLowerCase();
|
|
const fullName = `${patient.firstName} ${patient.lastName}`.toLowerCase();
|
|
const phone = patient.phone?.toLowerCase() || "";
|
|
const patientId = patient.id?.toString();
|
|
|
|
return (
|
|
fullName.includes(searchLower) ||
|
|
phone.includes(searchLower) ||
|
|
patientId?.includes(searchLower)
|
|
);
|
|
});
|
|
|
|
// Handle calling patient via Twilio
|
|
const handleCall = (patient: Patient) => {
|
|
if (!patient.phone?.trim()) {
|
|
toast({
|
|
title: "No Phone Number",
|
|
description: "This patient does not have a phone number on file.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
makeCallMutation.mutate({
|
|
to: patient.phone,
|
|
patientId: Number(patient.id),
|
|
});
|
|
};
|
|
|
|
// Handle sending SMS
|
|
const handleSMS = (patient: Patient) => {
|
|
setSelectedPatient(patient);
|
|
setIsSmsDialogOpen(true);
|
|
};
|
|
|
|
// Handle opening messaging
|
|
const handleOpenMessaging = (patient: Patient) => {
|
|
setSelectedPatient(patient);
|
|
setShowMessaging(true);
|
|
};
|
|
|
|
// Handle closing messaging
|
|
const handleCloseMessaging = () => {
|
|
setShowMessaging(false);
|
|
setSelectedPatient(null);
|
|
};
|
|
|
|
const { data: recentCommunications = [] } = useQuery<any[]>({
|
|
queryKey: ["/api/twilio/recent-communications"],
|
|
refetchInterval: 10000,
|
|
});
|
|
|
|
// One entry per patient, keeping the most recent communication
|
|
const recentPatients = Object.values(
|
|
recentCommunications.reduce((acc: Record<number, any>, comm: any) => {
|
|
if (!comm.patient) return acc;
|
|
if (!acc[comm.patient.id]) acc[comm.patient.id] = comm;
|
|
return acc;
|
|
}, {})
|
|
);
|
|
|
|
const callStats = [
|
|
{
|
|
label: "Total Calls Today",
|
|
value: "23",
|
|
icon: <Phone className="h-4 w-4" />,
|
|
},
|
|
{
|
|
label: "Answered Calls",
|
|
value: "21",
|
|
icon: <PhoneCall className="h-4 w-4" />,
|
|
},
|
|
{
|
|
label: "Average Call Time",
|
|
value: "4:32",
|
|
icon: <Clock className="h-4 w-4" />,
|
|
},
|
|
{
|
|
label: "Active Patients",
|
|
value: patients.length.toString(),
|
|
icon: <User className="h-4 w-4" />,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div className="container mx-auto space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">
|
|
Patient Connection
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
Search and communicate with patients
|
|
</p>
|
|
</div>
|
|
|
|
{/* After-hours AI toggle */}
|
|
<div className="flex items-center gap-2 px-4 py-2 rounded-lg border bg-white shadow-sm">
|
|
<MoonStar className={`h-4 w-4 ${afterHoursEnabled ? "text-primary" : "text-muted-foreground"}`} />
|
|
<span className="text-sm font-medium">Hand off to AI after hours</span>
|
|
<Switch
|
|
checked={afterHoursEnabled}
|
|
onCheckedChange={handleAfterHoursToggle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Call Statistics */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
{callStats.map((stat, index) => (
|
|
<Card key={index}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
{stat.label}
|
|
</p>
|
|
<p className="text-2xl font-bold">{stat.value}</p>
|
|
</div>
|
|
<div className="text-primary">{stat.icon}</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Recent Calls */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Recent Conversations</CardTitle>
|
|
<CardDescription>
|
|
View and manage recent patient conversations
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{recentPatients.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
No conversations yet.
|
|
</p>
|
|
) : (
|
|
recentPatients.map((comm: any) => (
|
|
<div
|
|
key={comm.patient.id}
|
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
|
onClick={() => handleOpenMessaging(comm.patient)}
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<div className="h-9 w-9 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
|
{comm.patient.firstName?.[0]}{comm.patient.lastName?.[0]}
|
|
</div>
|
|
<p className="font-medium">
|
|
{comm.patient.firstName} {comm.patient.lastName}
|
|
</p>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{comm.createdAt
|
|
? formatDistanceToNow(parseISO(comm.createdAt), { addSuffix: true })
|
|
: ""}
|
|
</p>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Search and Actions */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Search Patients</CardTitle>
|
|
<CardDescription>
|
|
Search by name, phone number, or patient ID
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex gap-4 mb-4">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search patient by name, phone, or ID..."
|
|
className="pl-8"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
data-testid="input-patient-search"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search Results */}
|
|
{searchTerm && (
|
|
<div className="mt-4">
|
|
{isLoading ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Loading patients...
|
|
</p>
|
|
) : filteredPatients.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium mb-2">
|
|
Search Results ({filteredPatients.length})
|
|
</p>
|
|
{filteredPatients.map((patient) => (
|
|
<div
|
|
key={patient.id}
|
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors"
|
|
data-testid={`patient-result-${patient.id}`}
|
|
>
|
|
<div className="flex-1">
|
|
<p className="font-medium">
|
|
{patient.firstName} {patient.lastName}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{patient.phone || "No phone number"} • ID:{" "}
|
|
{patient.id}
|
|
</p>
|
|
{patient.email && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{patient.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleCall(patient)}
|
|
data-testid={`button-call-${patient.id}`}
|
|
>
|
|
<Phone className="h-4 w-4 mr-1" />
|
|
Call
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleSMS(patient)}
|
|
data-testid={`button-sms-${patient.id}`}
|
|
>
|
|
<Send className="h-4 w-4 mr-1" />
|
|
SMS
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleOpenMessaging(patient)}
|
|
data-testid={`button-chat-${patient.id}`}
|
|
>
|
|
<MessageSquare className="h-4 w-4 mr-1" />
|
|
Chat
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
No patients found matching "{searchTerm}"
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* SMS Template Dialog */}
|
|
<SmsTemplateDialog
|
|
open={isSmsDialogOpen}
|
|
onOpenChange={setIsSmsDialogOpen}
|
|
patient={selectedPatient}
|
|
/>
|
|
|
|
{/* Messaging Interface */}
|
|
{showMessaging && selectedPatient && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
|
<div className="w-full max-w-4xl h-[80vh] bg-white rounded-lg shadow-xl relative">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute top-4 right-4 z-10"
|
|
onClick={handleCloseMessaging}
|
|
data-testid="button-close-messaging"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
<div className="h-full">
|
|
<MessageThread
|
|
patient={selectedPatient}
|
|
onBack={handleCloseMessaging}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|