feat: add Twilio SMS/call integration with settings, templates, and conversation history
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user