feat: add new frontend components, MH batch worker, and gitignore rules

- Add all new Frontend source files (pages, components, hooks, utils)
- Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py
- Add install-steps-5-13.sh setup script
- Update .gitignore to exclude runtime/sensitive data (backups, uploads,
  chat-history, keys, downloads, generated .d.ts files) while keeping folders
- Add .gitkeep to preserve empty runtime folders in git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:23:43 -04:00
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Device } from "@twilio/voice-sdk";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Phone, PhoneOff, Mic, MicOff, Delete } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
const KEYS = [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
["*", "0", "#"],
];
function formatDuration(seconds) {
const m = Math.floor(seconds / 60).toString().padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}
export function DialPad() {
const [dialedNumber, setDialedNumber] = useState("");
const [status, setStatus] = useState("idle");
const [isMuted, setIsMuted] = useState(false);
const [duration, setDuration] = useState(0);
const { toast } = useToast();
const deviceRef = useRef(null);
const callRef = useRef(null);
const timerRef = useRef(null);
const stopTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const destroyDevice = useCallback(() => {
callRef.current?.disconnect();
callRef.current = null;
deviceRef.current?.destroy();
deviceRef.current = null;
}, []);
useEffect(() => {
return () => {
stopTimer();
destroyDevice();
};
}, [stopTimer, destroyDevice]);
// Keyboard input support
useEffect(() => {
const handleKey = (e) => {
if (status !== "idle")
return;
const valid = "0123456789*#";
if (valid.includes(e.key)) {
setDialedNumber((n) => (n.length < 16 ? n + e.key : n));
}
else if (e.key === "Backspace") {
setDialedNumber((n) => n.slice(0, -1));
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [status]);
const pressKey = (key) => {
if (status !== "idle")
return;
setDialedNumber((n) => (n.length < 16 ? n + key : n));
};
const handleBackspace = () => {
if (status !== "idle")
return;
setDialedNumber((n) => n.slice(0, -1));
};
const handleCall = async () => {
if (!dialedNumber.trim()) {
toast({ title: "Enter a number", description: "Please dial a phone number first.", variant: "destructive" });
return;
}
setStatus("requesting-token");
setDuration(0);
setIsMuted(false);
try {
const res = await apiRequest("POST", "/api/twilio/voice-token");
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to get voice token");
}
const { token } = await res.json();
const device = new Device(token, { logLevel: "error" });
deviceRef.current = device;
await device.register();
setStatus("connecting");
// Normalize to E.164 if it looks like a 10-digit US number
let toNumber = dialedNumber.replace(/\D/g, "");
if (toNumber.length === 10)
toNumber = "+1" + toNumber;
else if (!toNumber.startsWith("+"))
toNumber = "+" + toNumber;
const call = await device.connect({ params: { To: toNumber } });
callRef.current = call;
call.on("accept", () => {
setStatus("connected");
timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000);
});
call.on("disconnect", () => {
stopTimer();
destroyDevice();
setStatus("idle");
setDuration(0);
setIsMuted(false);
});
call.on("error", (err) => {
stopTimer();
destroyDevice();
setStatus("error");
toast({ title: "Call Error", description: err?.message || "Call failed.", variant: "destructive" });
setTimeout(() => setStatus("idle"), 3000);
});
}
catch (err) {
destroyDevice();
setStatus("error");
toast({ title: "Call Failed", description: err?.message || "Unable to place call.", variant: "destructive" });
setTimeout(() => setStatus("idle"), 3000);
}
};
const handleHangup = () => {
callRef.current?.disconnect();
};
const handleMute = () => {
if (!callRef.current)
return;
const next = !isMuted;
callRef.current.mute(next);
setIsMuted(next);
};
const statusLabel = {
idle: "Ready",
"requesting-token": "Initializing...",
connecting: "Connecting...",
connected: `In Call ${formatDuration(duration)}`,
error: "Error",
};
const statusColor = {
idle: "text-muted-foreground",
"requesting-token": "text-amber-500",
connecting: "text-amber-500",
connected: "text-green-600",
error: "text-red-500",
};
const isInCall = status === "connected" || status === "connecting";
const isBusy = status === "requesting-token" || status === "connecting";
return (<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Phone className="h-4 w-4"/>
Dial Pad
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center gap-3 max-w-xs mx-auto">
{/* Number display */}
<div className="w-full flex items-center gap-2 px-3 py-2 border rounded-lg bg-gray-50 min-h-[42px]">
<span className="flex-1 font-mono text-lg tracking-widest text-center">
{dialedNumber || <span className="text-muted-foreground text-sm">Enter number</span>}
</span>
{dialedNumber && status === "idle" && (<button onClick={handleBackspace} className="text-muted-foreground hover:text-foreground">
<Delete className="h-4 w-4"/>
</button>)}
</div>
{/* Keypad */}
<div className="grid grid-cols-3 gap-2 w-full">
{KEYS.flat().map((key) => (<button key={key} onClick={() => pressKey(key)} disabled={isInCall || isBusy} className="h-12 rounded-lg border bg-white text-lg font-medium hover:bg-gray-50 active:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{key}
</button>))}
</div>
{/* Call / Hangup */}
<div className="flex gap-3 w-full">
{!isInCall ? (<Button className="flex-1 bg-green-600 hover:bg-green-700 text-white" onClick={handleCall} disabled={isBusy || !dialedNumber.trim()}>
<Phone className="h-4 w-4 mr-2"/>
Call
</Button>) : (<>
<Button variant="outline" onClick={handleMute} className={isMuted ? "border-red-300 text-red-600" : ""}>
{isMuted ? <MicOff className="h-4 w-4"/> : <Mic className="h-4 w-4"/>}
</Button>
<Button className="flex-1 bg-red-600 hover:bg-red-700 text-white" onClick={handleHangup}>
<PhoneOff className="h-4 w-4 mr-2"/>
Hang Up
</Button>
</>)}
</div>
{/* Status */}
<p className={`text-sm font-medium ${statusColor[status]}`}>
{statusLabel[status]}
</p>
</div>
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,485 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { format, isToday, isYesterday, parseISO } from "date-fns";
import { es, pt, zhCN, zhTW, ar, fr } from "date-fns/locale";
// ─── Language config ──────────────────────────────────────────────────────────
export const SUPPORTED_LANGUAGES = [
"English",
"Spanish",
"Portuguese",
"Mandarin",
"Cantonese",
"Arabic",
"Haitian Creole",
];
const LOCALES = {
Spanish: es,
Portuguese: pt,
Mandarin: zhCN,
Cantonese: zhTW,
Arabic: ar,
"Haitian Creole": fr, // French locale — closest approximation for day/month names
};
// Date pattern per language (date-fns format tokens)
const DATE_FMT = {
English: "MMMM do, EEEE",
Spanish: "EEEE, d 'de' MMMM",
Portuguese: "EEEE, d 'de' MMMM",
Mandarin: "M月d日EEEE",
Cantonese: "M月d日EEEE",
Arabic: "EEEE d MMMM",
"Haitian Creole": "EEEE d MMMM",
};
function formatTime(h, m, lang) {
const pad = (n) => String(n).padStart(2, "0");
const h12 = h % 12 || 12;
if (lang === "Mandarin" || lang === "Cantonese") {
return `${h >= 12 ? "下午" : "上午"}${h12}:${pad(m)}`;
}
if (lang === "Arabic") {
return `${h12}:${pad(m)} ${h >= 12 ? "مساءً" : "صباحاً"}`;
}
if (lang === "Spanish" || lang === "Portuguese") {
return `${pad(h)}:${pad(m)}`;
}
// English, Haitian Creole
return `${h12}:${pad(m)} ${h >= 12 ? "pm" : "am"}`;
}
function buildApptPhrase(date, startTime, lang) {
const [y, mo, d] = date.split("-").map(Number);
const dateObj = new Date(y, mo - 1, d);
const locale = LOCALES[lang];
const datePart = format(dateObj, DATE_FMT[lang], { locale });
const [h, m] = startTime.split(":").map(Number);
const timePart = formatTime(h, m, lang);
switch (lang) {
case "Spanish": return `el ${datePart} a las ${timePart}`;
case "Portuguese": return `na ${datePart} às ${timePart}`;
case "Mandarin": return `${datePart}${timePart}`;
case "Cantonese": return `${datePart}${timePart}`;
case "Arabic": return `في ${datePart} الساعة ${timePart}`;
case "Haitian Creole": return `le ${datePart} a ${timePart}`;
default: return `on ${datePart} at ${timePart}`;
}
}
const DEFAULT_OFFICE_NAME = {
English: "the dental office",
Spanish: "la clínica dental",
Portuguese: "o consultório dentário",
Mandarin: "牙科诊所",
Cantonese: "牙科診所",
Arabic: "عيادة الأسنان",
"Haitian Creole": "kabinè dantis la",
};
function callPhrase(phone, lang) {
if (!phone?.trim()) {
return { English: "call us", Spanish: "llámenos", Portuguese: "ligue-nos",
Mandarin: "联系我们", Cantonese: "聯繫我們", Arabic: "اتصل بنا",
"Haitian Creole": "rele nou" }[lang];
}
return {
English: `call us at ${phone}`,
Spanish: `llámenos al ${phone}`,
Portuguese: `ligue-nos pelo ${phone}`,
Mandarin: `致电 ${phone}`,
Cantonese: `致電 ${phone}`,
Arabic: `اتصل بنا على ${phone}`,
"Haitian Creole": `rele nou nan ${phone}`,
}[lang];
}
function buildTemplates(patient, lang, apptInfo, office) {
const n = patient.firstName;
const o = office?.officeName?.trim() || DEFAULT_OFFICE_NAME[lang];
const c = callPhrase(office?.phoneNumber, lang);
const appt = apptInfo ? buildApptPhrase(apptInfo.date, apptInfo.startTime, lang) : "";
const T = {
English: {
label_reminder: "Appointment Reminder",
label_confirmed: "Appointment Confirmed",
label_followup: "Follow-Up",
label_payment: "Payment Reminder",
label_general: "General",
reminder: apptInfo
? `Hi ${n}, this is ${o}. Reminder: You have an appointment scheduled ${appt}. Please confirm through text messages or ${c} if you need to reschedule.`
: `Hi ${n}, this is ${o}. Reminder: You have an upcoming appointment. Please confirm through text messages or ${c} if you need to reschedule.`,
confirmed: `Hi ${n}, this is ${o}. Your appointment has been confirmed. We look forward to seeing you! If you have any questions, please ${c}.`,
followup: `Hi ${n}, this is ${o}. Thank you for visiting us. How are you feeling after your treatment? Please let us know if you have any concerns.`,
payment: `Hi ${n}, this is ${o}. This is a friendly reminder about your outstanding balance. Please ${c} to discuss payment options.`,
general: `Hi ${n}, this is ${o}. `,
},
Spanish: {
label_reminder: "Recordatorio de cita",
label_confirmed: "Cita confirmada",
label_followup: "Seguimiento",
label_payment: "Recordatorio de pago",
label_general: "General",
reminder: apptInfo
? `Hola ${n}, le habla ${o}. Recordatorio: Tiene una cita programada ${appt}. Por favor confirme por mensaje de texto o ${c} si necesita reprogramar.`
: `Hola ${n}, le habla ${o}. Recordatorio: Tiene una próxima cita. Por favor confirme por mensaje de texto o ${c} si necesita reprogramar.`,
confirmed: `Hola ${n}, le habla ${o}. Su cita ha sido confirmada. ¡Esperamos verle pronto! Si tiene alguna pregunta, por favor ${c}.`,
followup: `Hola ${n}, le habla ${o}. Gracias por visitarnos. ¿Cómo se siente después de su tratamiento? Por favor, háganos saber si tiene alguna inquietud.`,
payment: `Hola ${n}, le habla ${o}. Este es un recordatorio amable sobre su saldo pendiente. Por favor ${c} para hablar sobre las opciones de pago.`,
general: `Hola ${n}, le habla ${o}. `,
},
Portuguese: {
label_reminder: "Lembrete de consulta",
label_confirmed: "Consulta confirmada",
label_followup: "Acompanhamento",
label_payment: "Lembrete de pagamento",
label_general: "Geral",
reminder: apptInfo
? `Olá ${n}, aqui é ${o}. Lembrete: Você tem uma consulta agendada ${appt}. Por favor confirme por mensagem de texto ou ${c} se precisar reagendar.`
: `Olá ${n}, aqui é ${o}. Lembrete: Você tem uma consulta próxima. Por favor confirme por mensagem de texto ou ${c} se precisar reagendar.`,
confirmed: `Olá ${n}, aqui é ${o}. Sua consulta foi confirmada. Aguardamos sua visita! Se tiver alguma dúvida, por favor ${c}.`,
followup: `Olá ${n}, aqui é ${o}. Obrigado pela sua visita. Como está se sentindo após o tratamento? Por favor, nos informe se tiver alguma preocupação.`,
payment: `Olá ${n}, aqui é ${o}. Este é um lembrete amigável sobre o seu saldo pendente. Por favor ${c} para discutir as opções de pagamento.`,
general: `Olá ${n}, aqui é ${o}. `,
},
Mandarin: {
label_reminder: "预约提醒",
label_confirmed: "预约确认",
label_followup: "回访",
label_payment: "付款提醒",
label_general: "一般消息",
reminder: apptInfo
? `您好 ${n},我们是${o}。提醒:您有一个预约安排${appt}。请通过短信确认,或如需重新安排请${c}`
: `您好 ${n},我们是${o}。提醒:您有一个即将到来的预约。请通过短信确认,或如需重新安排请${c}`,
confirmed: `您好 ${n},我们是${o}。您的预约已确认。期待您的光临!如有疑问,请${c}`,
followup: `您好 ${n},我们是${o}。感谢您的来访。治疗后您感觉怎么样?如有任何疑虑,请告诉我们。`,
payment: `您好 ${n},我们是${o}。这是关于您未付余额的友好提醒。请${c}讨论付款方案。`,
general: `您好 ${n},我们是${o}`,
},
Cantonese: {
label_reminder: "預約提醒",
label_confirmed: "預約確認",
label_followup: "跟進",
label_payment: "付款提醒",
label_general: "一般消息",
reminder: apptInfo
? `您好 ${n},我哋係${o}。提醒:您有一個預約安排${appt}。請透過短訊確認,或如需重新安排請${c}`
: `您好 ${n},我哋係${o}。提醒:您有一個即將到嘅預約。請透過短訊確認,或如需重新安排請${c}`,
confirmed: `您好 ${n},我哋係${o}。您嘅預約已確認。期待見到您!如有疑問,請${c}`,
followup: `您好 ${n},我哋係${o}。感謝您嘅到訪。治療後您感覺點樣?如有任何疑慮,請告知我們。`,
payment: `您好 ${n},我哋係${o}。呢係關於您未付餘額嘅友好提醒。請${c}討論付款方案。`,
general: `您好 ${n},我哋係${o}`,
},
Arabic: {
label_reminder: "تذكير بالموعد",
label_confirmed: "تأكيد الموعد",
label_followup: "متابعة",
label_payment: "تذكير بالدفع",
label_general: "رسالة عامة",
reminder: apptInfo
? `مرحباً ${n}، نحن ${o}. تذكير: لديك موعد مجدول ${appt}. يرجى التأكيد عبر الرسائل النصية أو ${c} إذا كنت بحاجة إلى إعادة الجدولة.`
: `مرحباً ${n}، نحن ${o}. تذكير: لديك موعد قادم. يرجى التأكيد عبر الرسائل النصية أو ${c} إذا كنت بحاجة إلى إعادة الجدولة.`,
confirmed: `مرحباً ${n}، نحن ${o}. تم تأكيد موعدك. نتطلع إلى رؤيتك! إذا كان لديك أي أسئلة، يرجى ${c}.`,
followup: `مرحباً ${n}، نحن ${o}. شكراً لزيارتك. كيف تشعر بعد العلاج؟ يرجى إبلاغنا إذا كان لديك أي مخاوف.`,
payment: `مرحباً ${n}، نحن ${o}. هذا تذكير ودي بشأن رصيدك المستحق. يرجى ${c} لمناقشة خيارات الدفع.`,
general: `مرحباً ${n}، نحن ${o}. `,
},
"Haitian Creole": {
label_reminder: "Rapèl randevou",
label_confirmed: "Randevou konfime",
label_followup: "Swivi",
label_payment: "Rapèl peman",
label_general: "Jeneral",
reminder: apptInfo
? `Bonjou ${n}, se ${o} k'ap pale. Rapèl: Ou gen yon randevou pwograme ${appt}. Tanpri konfime pa mesaj tèks oswa ${c} si ou bezwen repwograme.`
: `Bonjou ${n}, se ${o} k'ap pale. Rapèl: Ou gen yon randevou k'ap vini. Tanpri konfime pa mesaj tèks oswa ${c} si ou bezwen repwograme.`,
confirmed: `Bonjou ${n}, se ${o} k'ap pale. Randevou ou a konfime. N'ap tann ou! Si ou gen kesyon, tanpri ${c}.`,
followup: `Bonjou ${n}, se ${o} k'ap pale. Mèsi dèske ou te vizite nou. Kijan ou santi ou apre tretman ou? Tanpri fè nou konnen si ou gen nenpòt enkyetid.`,
payment: `Bonjou ${n}, se ${o} k'ap pale. Sa se yon rapèl amical sou balans ou ki annatant. Tanpri ${c} pou diskite opsyon peman.`,
general: `Bonjou ${n}, se ${o} k'ap pale. `,
},
}[lang];
return [
{ key: "appointment_reminder", label: T.label_reminder, body: T.reminder },
{ key: "appointment_confirmed", label: T.label_confirmed, body: T.confirmed },
{ key: "follow_up", label: T.label_followup, body: T.followup },
{ key: "payment_reminder", label: T.label_payment, body: T.payment },
{ key: "general", label: T.label_general, body: T.general },
];
}
export function MessageThread({ patient, onBack, appointmentInfo }) {
const { toast } = useToast();
const [messageText, setMessageText] = useState("");
const [language, setLanguage] = useState(patient.preferredLanguage &&
SUPPORTED_LANGUAGES.includes(patient.preferredLanguage)
? patient.preferredLanguage
: "English");
const messagesEndRef = useRef(null);
const [handOffToAI, setHandOffToAI] = useState(true);
const [pendingStartFlow, setPendingStartFlow] = useState(null);
useQuery({
queryKey: ["/api/twilio/ai-handoff", patient.id],
queryFn: async () => {
const res = await apiRequest("GET", `/api/twilio/ai-handoff/${patient.id}`);
return res.json();
},
onSuccess: (data) => setHandOffToAI(data.enabled),
});
const { data: aiChatTemplates } = useQuery({
queryKey: ["/api/ai/chat-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/chat-templates");
if (!res.ok)
return null;
return res.json();
},
staleTime: 60_000,
});
const handoffMutation = useMutation({
mutationFn: async (enabled) => apiRequest("PUT", `/api/twilio/ai-handoff/${patient.id}`, { enabled }),
});
const handleHandoffToggle = (enabled) => {
setHandOffToAI(enabled);
handoffMutation.mutate(enabled);
};
const { data: officeContact } = useQuery({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok)
return null;
return res.json();
},
staleTime: 60_000,
});
const { data: communications = [], isLoading } = useQuery({
queryKey: ["/api/patients", patient.id, "communications"],
queryFn: async () => {
const res = await apiRequest("GET", `/api/patients/${patient.id}/communications`);
return res.json();
},
refetchInterval: 5000,
});
const sendMessageMutation = useMutation({
mutationFn: async (message) => apiRequest("POST", "/api/twilio/send-sms", {
to: patient.phone,
message,
patientId: patient.id,
...(pendingStartFlow ? { startFlow: pendingStartFlow } : {}),
}),
onSuccess: () => {
setMessageText("");
setPendingStartFlow(null);
queryClient.invalidateQueries({ queryKey: ["/api/patients", patient.id, "communications"] });
toast({ title: "Message sent", description: "Your message has been sent successfully." });
},
onError: (error) => {
toast({
title: "Failed to send message",
description: error.message || "Unable to send message. Please try again.",
variant: "destructive",
});
},
});
const handleSendMessage = () => {
if (!messageText.trim())
return;
sendMessageMutation.mutate(messageText);
};
const handleKeyPress = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [communications]);
const formatMessageDate = (dateValue) => {
const date = typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(date))
return format(date, "h:mm a");
if (isYesterday(date))
return `Yesterday ${format(date, "h:mm a")}`;
return format(date, "MMM d, h:mm a");
};
const getDateDivider = (dateValue) => {
const d = typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(d))
return "Today";
if (isYesterday(d))
return "Yesterday";
return format(d, "MMMM d, yyyy");
};
const groupedMessages = [];
communications.forEach((comm) => {
if (!comm.createdAt)
return;
const messageDate = typeof comm.createdAt === "string" ? parseISO(comm.createdAt) : comm.createdAt;
const dateKey = format(messageDate, "yyyy-MM-dd");
const existing = groupedMessages.find((g) => g.date === dateKey);
if (existing)
existing.messages.push(comm);
else
groupedMessages.push({ date: dateKey, messages: [comm] });
});
const templates = buildTemplates(patient, language, appointmentInfo, officeContact);
return (<div className="flex flex-col h-full bg-white rounded-lg border">
{/* Header */}
<div className="p-4 border-b bg-gray-50 space-y-2">
{/* Patient identity row */}
<div className="flex items-center gap-3">
{onBack && (<Button variant="ghost" size="icon" onClick={onBack} data-testid="button-back">
<ArrowLeft className="h-5 w-5"/>
</Button>)}
<div className="h-10 w-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold flex-shrink-0">
{patient.firstName[0]}{patient.lastName[0]}
</div>
<div>
<h3 className="font-semibold text-base">
{patient.firstName} {patient.lastName}
</h3>
<p className="text-sm text-muted-foreground">{patient.phone}</p>
</div>
</div>
{/* Controls row: language + template */}
<div className="flex items-center gap-2 pl-1 flex-wrap">
{/* Language selector */}
<div className="flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0"/>
<Select value={language} onValueChange={(v) => setLanguage(v)}>
<SelectTrigger className="h-7 text-xs w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((l) => (<SelectItem key={l} value={l} className="text-xs">{l}</SelectItem>))}
</SelectContent>
</Select>
</div>
{/* Template selector */}
<div className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0"/>
<Select value="" onValueChange={(key) => {
if (key === "__new_patient__") {
const greeting = aiChatTemplates?.newPatientGreeting ||
"Hi! My name is Lisa, the dedicated AI assistant at our dental office. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?";
setMessageText(greeting);
setPendingStartFlow("new_patient");
}
else if (key === "__reschedule__") {
const greeting = aiChatTemplates?.rescheduleGreeting ||
"Hi! My name is Lisa, the dedicated AI assistant at our dental office. I can help you find a new appointment time that works for you. Would you like to reschedule your appointment?";
setMessageText(greeting);
setPendingStartFlow("reschedule");
}
else {
const tpl = templates.find((t) => t.key === key);
if (tpl) {
setMessageText(tpl.body);
setPendingStartFlow(null);
}
}
}}>
<SelectTrigger className="h-7 text-xs border-dashed w-[180px]">
<SelectValue placeholder="Use a template…"/>
</SelectTrigger>
<SelectContent>
{/* New patient scheduling — uses AI New Patient Greeting */}
<SelectItem value="__new_patient__" className="text-xs font-medium text-primary">
<span className="flex items-center gap-1.5">
<UserPlus className="h-3 w-3"/>
Schedule a New Patient
</span>
</SelectItem>
{/* Reschedule patients — uses AI Reschedule Greeting */}
<SelectItem value="__reschedule__" className="text-xs font-medium text-primary">
<span className="flex items-center gap-1.5">
<CalendarX className="h-3 w-3"/>
Reschedule Patients
</span>
</SelectItem>
<div className="my-1 border-t"/>
{templates.map((t) => (<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
</SelectItem>))}
</SelectContent>
</Select>
</div>
{/* New-patient flow indicator */}
{pendingStartFlow === "new_patient" && (<div className="flex items-center gap-1 text-xs text-primary bg-primary/5 border border-primary/20 rounded px-2 py-0.5">
<UserPlus className="h-3 w-3"/>
New patient flow
</div>)}
{/* Reschedule flow indicator */}
{pendingStartFlow === "reschedule" && (<div className="flex items-center gap-1 text-xs text-primary bg-primary/5 border border-primary/20 rounded px-2 py-0.5">
<CalendarX className="h-3 w-3"/>
Reschedule flow
</div>)}
{/* AI handoff toggle */}
<div className="flex items-center gap-1.5 ml-auto">
<Bot className={`h-3.5 w-3.5 flex-shrink-0 ${handOffToAI ? "text-primary" : "text-muted-foreground"}`}/>
<span className="text-xs text-muted-foreground">Hand off to AI</span>
<Switch checked={handOffToAI} onCheckedChange={handleHandoffToggle} className="scale-75 origin-left"/>
</div>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4" data-testid="messages-container">
{isLoading ? (<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading messages...</p>
</div>) : communications.length === 0 ? (<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">No messages yet. Start the conversation!</p>
</div>) : (<>
{groupedMessages.map((group) => (<div key={group.date}>
<div className="flex items-center justify-center my-8">
<div className="px-4 py-1 bg-gray-100 rounded-full text-xs text-muted-foreground">
{getDateDivider(group.messages[0]?.createdAt)}
</div>
</div>
{group.messages.map((comm) => (<div key={comm.id} className={`flex mb-4 ${comm.direction === "outbound" ? "justify-end" : "justify-start"}`} data-testid={`message-${comm.id}`}>
<div className={`max-w-md ${comm.direction === "outbound" ? "ml-auto" : "mr-auto"}`}>
{comm.direction === "inbound" && (<div className="flex items-start gap-2">
<div className="h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center text-xs font-semibold flex-shrink-0">
{patient.firstName[0]}{patient.lastName[0]}
</div>
<div>
<div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
{comm.createdAt && formatMessageDate(comm.createdAt)}
</p>
</div>
</div>)}
{comm.direction === "outbound" && (<div>
<div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div>
<p className="text-xs text-muted-foreground mt-1 text-right">
{comm.createdAt && formatMessageDate(comm.createdAt)}
</p>
</div>)}
</div>
</div>))}
</div>))}
<div ref={messagesEndRef}/>
</>)}
</div>
{/* Input Area */}
<div className="p-4 border-t bg-gray-50">
<div className="flex items-center gap-2">
<Input value={messageText} onChange={(e) => setMessageText(e.target.value)} onKeyPress={handleKeyPress} placeholder="Type your message..." className="flex-1 rounded-full" disabled={sendMessageMutation.isPending} data-testid="input-message"/>
<Button onClick={handleSendMessage} disabled={!messageText.trim() || sendMessageMutation.isPending} size="icon" className="rounded-full h-10 w-10" data-testid="button-send">
<Send className="h-4 w-4"/>
</Button>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,157 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { MessageSquare, Send, Loader2, Save } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
const DEFAULT_TEMPLATES = {
appointment_reminder: {
name: "Appointment Reminder",
body: (firstName) => `Hi ${firstName}, this is your dental office. Reminder: You have an appointment scheduled. Please confirm or call us if you need to reschedule.`,
},
appointment_confirmation: {
name: "Appointment Confirmation",
body: (firstName) => `Hi ${firstName}, your appointment has been confirmed. We look forward to seeing you! If you have any questions, please call our office.`,
},
follow_up: {
name: "Follow-Up",
body: (firstName) => `Hi ${firstName}, thank you for visiting our dental office. How are you feeling after your treatment? Please let us know if you have any concerns.`,
},
payment_reminder: {
name: "Payment Reminder",
body: (firstName) => `Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`,
},
general: {
name: "General Message",
body: (firstName) => `Hi ${firstName}, this is your dental office. `,
},
custom: {
name: "Custom Message",
body: () => "",
},
};
const TEMPLATE_KEYS = Object.keys(DEFAULT_TEMPLATES);
function getDefaultBody(key, firstName) {
const t = DEFAULT_TEMPLATES[key];
if (!t)
return "";
return typeof t.body === "function" ? t.body(firstName) : t.body;
}
export function SmsTemplateDialog({ open, onOpenChange, patient, }) {
const [selectedKey, setSelectedKey] = useState("appointment_reminder");
const [messageText, setMessageText] = useState("");
const { toast } = useToast();
const { data: savedTemplates = {} } = useQuery({
queryKey: ["/api/twilio/templates"],
enabled: open,
});
// Resolve effective body for a given key (saved override or default)
const resolveBody = (key) => {
if (key === "custom")
return "";
if (savedTemplates[key]) {
// Replace placeholder first name with actual patient name
return savedTemplates[key].replace(/^Hi \w+,/, `Hi ${patient?.firstName ?? ""},`);
}
return getDefaultBody(key, patient?.firstName ?? "");
};
// When dialog opens or template changes, populate message
useEffect(() => {
if (open)
setMessageText(resolveBody(selectedKey));
}, [open, selectedKey, savedTemplates, patient?.firstName]);
const sendMutation = useMutation({
mutationFn: async (message) => {
return apiRequest("POST", "/api/twilio/send-sms", {
to: patient.phone,
message,
patientId: patient.id,
});
},
onSuccess: () => {
toast({ title: "SMS Sent", description: `Message sent to ${patient?.firstName} ${patient?.lastName}` });
onOpenChange(false);
setSelectedKey("appointment_reminder");
setMessageText("");
},
onError: (err) => {
toast({ title: "Failed to Send SMS", description: err.message || "Please check your Twilio configuration.", variant: "destructive" });
},
});
const updateMutation = useMutation({
mutationFn: async ({ key, body }) => {
return apiRequest("PUT", `/api/twilio/templates/${key}`, { body });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/twilio/templates"] });
toast({ title: "Template Updated", description: "This template will be used going forward." });
},
onError: (err) => {
toast({ title: "Failed to Update Template", description: err.message, variant: "destructive" });
},
});
const handleTemplateChange = (key) => {
setSelectedKey(key);
setMessageText(resolveBody(key));
};
return (<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5"/>
Send SMS to {patient?.firstName} {patient?.lastName}
</DialogTitle>
<DialogDescription>
Choose a template or write a custom message
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Message Template</Label>
<Select value={selectedKey} onValueChange={handleTemplateChange}>
<SelectTrigger data-testid="select-sms-template">
<SelectValue placeholder="Select a template"/>
</SelectTrigger>
<SelectContent>
{TEMPLATE_KEYS.map((key) => (<SelectItem key={key} value={key}>
{DEFAULT_TEMPLATES[key]?.name ?? key}
{savedTemplates[key] ? " ✎" : ""}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Message</Label>
{selectedKey !== "custom" && (<Button type="button" variant="outline" size="sm" className="h-7 text-xs gap-1" disabled={!messageText.trim() || updateMutation.isPending} onClick={() => updateMutation.mutate({ key: selectedKey, body: messageText })}>
{updateMutation.isPending ? (<Loader2 className="h-3 w-3 animate-spin"/>) : (<Save className="h-3 w-3"/>)}
{updateMutation.isPending ? "Saving..." : "Update Template"}
</Button>)}
</div>
<Textarea value={messageText} onChange={(e) => setMessageText(e.target.value)} placeholder="Type your message here..." rows={5} className="resize-none" data-testid="textarea-sms-message"/>
<p className="text-xs text-muted-foreground">
{patient?.phone
? `Will be sent to: ${patient.phone}`
: "No phone number available"}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => sendMutation.mutate(messageText)} disabled={!patient?.phone || !messageText.trim() || sendMutation.isPending || !patient} className="gap-2" data-testid="button-send-sms">
{sendMutation.isPending ? (<Loader2 className="h-4 w-4 animate-spin"/>) : (<Send className="h-4 w-4"/>)}
{sendMutation.isPending ? "Sending..." : "Send SMS"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>);
}