feat: add Twilio SMS/call integration with settings, templates, and conversation history

This commit is contained in:
Gitead
2026-05-02 20:18:58 -04:00
parent b73b8c97c6
commit 5689269690
17 changed files with 770 additions and 196 deletions

View File

@@ -1,4 +1,5 @@
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";
@@ -19,6 +20,7 @@ import {
User,
Search,
MessageSquare,
Send,
X,
} from "lucide-react";
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
@@ -92,12 +94,18 @@ export default function PatientConnectionPage() {
// Handle calling patient via Twilio
const handleCall = (patient: Patient) => {
if (patient.phone) {
makeCallMutation.mutate({
to: patient.phone,
patientId: Number(patient.id),
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
@@ -118,36 +126,19 @@ export default function PatientConnectionPage() {
setSelectedPatient(null);
};
// Sample call data
const recentCalls = [
{
id: 1,
patientName: "John Bill",
phoneNumber: "(555) 123-4567",
callType: "Appointment Request",
status: "Completed",
duration: "3:45",
time: "2 hours ago",
},
{
id: 2,
patientName: "Emily Brown",
phoneNumber: "(555) 987-6543",
callType: "Insurance Question",
status: "Follow-up Required",
duration: "6:12",
time: "4 hours ago",
},
{
id: 3,
patientName: "Mike Johnson",
phoneNumber: "(555) 456-7890",
callType: "Prescription Refill",
status: "Completed",
duration: "2:30",
time: "6 hours ago",
},
];
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 = [
{
@@ -206,8 +197,49 @@ export default function PatientConnectionPage() {
))}
</div>
{/* Search and Actions */}
{/* 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>
@@ -265,17 +297,24 @@ export default function PatientConnectionPage() {
variant="outline"
size="sm"
onClick={() => handleCall(patient)}
disabled={!patient.phone}
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)}
disabled={!patient.phone}
data-testid={`button-chat-${patient.id}`}
>
<MessageSquare className="h-4 w-4 mr-1" />
@@ -295,57 +334,6 @@ export default function PatientConnectionPage() {
</CardContent>
</Card>
{/* Recent Calls */}
<Card>
<CardHeader>
<CardTitle>Recent Calls</CardTitle>
<CardDescription>
View and manage recent patient calls
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentCalls.map((call) => (
<div
key={call.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<Phone className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">{call.patientName}</p>
<p className="text-sm text-muted-foreground">
{call.phoneNumber}
</p>
</div>
<div>
<p className="text-sm font-medium">{call.callType}</p>
<p className="text-xs text-muted-foreground">
Duration: {call.duration}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge
variant={
call.status === "Completed" ? "default" : "secondary"
}
>
{call.status}
</Badge>
<p className="text-xs text-muted-foreground">{call.time}</p>
<Button variant="outline" size="sm">
<PhoneCall className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* SMS Template Dialog */}
<SmsTemplateDialog
open={isSmsDialogOpen}

View File

@@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Staff } from "@repo/db/types";
import { NpiProviderTable } from "@/components/settings/npiProviderTable";
import { ProgramBridgeTable } from "@/components/settings/program-bridge-table";
import { TwilioSettingsCard } from "@/components/settings/twilio-settings-card";
export default function SettingsPage() {
const { toast } = useToast();
@@ -511,6 +512,11 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Twilio Section */}
<div className="mt-6">
<TwilioSettingsCard />
</div>
{/* Credential Section */}
<div className="mt-6">
<CredentialTable />