import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
// ─── Defaults ─────────────────────────────────────────────────────────────────
const DEFAULT_REMINDER_SMS = "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please come to our office at {officeAddress}. Please reply YES to confirm or NO to reschedule. Thank you!";
const DEFAULTS = {
reminderGreeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply you message at any time you need.",
newPatientGreeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?",
generalFallback: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?",
};
const DEFAULT_SMS_TEMPLATES = [
{
id: "default-appt-reminder",
name: "Appointment Reminder SMS",
body: DEFAULT_REMINDER_SMS,
},
{
id: "default-follow-up",
name: "Follow up reminder",
body: "Hi {firstName}, this is a follow-up from {officeName}. We wanted to check in with you after your recent appointment. Please don't hesitate to call us if you have any questions.",
},
];
function previewTemplate(text, officeName) {
return text.replace(/\{officeName\}/g, officeName || "your dental office");
}
function newId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
// ─── Combined Reminder & Reschedule flow diagram (SVG) ────────────────────────
function LangGraphFlow() {
const W = 700;
const cx = 350;
const nW = 210;
const nx = cx - nW / 2; // 245
// ── Dual entry nodes ──────────────────────────────────────────────────────
const entryW = 192;
const lEntryCx = 155;
const rEntryCx = 545;
const e1y = 14, e1h = 56;
// Merge connector (horizontal line where both branches meet)
const mergeY = e1y + e1h + 14; // 84
// ── Center sequence ───────────────────────────────────────────────────────
const n2y = mergeY + 14, n2h = 52; // Patient replies y=98
const n3y = n2y + n2h + 14, n3h = 84; // AI classifies y=164
const forkHY = n3y + n3h + 22; // 270
// ── Branch centers ────────────────────────────────────────────────────────
const lcx = 140; // YES (left)
const rcx = 490; // NO / Reschedule (right)
// ── YES branch ────────────────────────────────────────────────────────────
const yesW = 195;
const yes1y = forkHY + 50, yes1h = 88; // y=320
const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460
// ── NO / Reschedule branch ────────────────────────────────────────────────
const noW = 208;
const failX = rcx + noW / 2 + 8; // 602
const failW = 92;
const no1y = forkHY + 50, no1h = 84; // y=320 ask date / shortcut
const no2y = no1y + no1h + 14, no2h = 82; // y=418 day check
const no3y = no2y + no2h + 14, no3h = 64; // y=514 ask time
const no4y = no3y + no3h + 14, no4h = 82; // y=592 time check
const no5y = no4y + no4h + 14, no5h = 64; // y=688 confirm datetime
const no6y = no5y + no5h + 14, no6h = 74; // y=766 patient YES/NO
const no7y = no6y + no6h + 14, no7h = 82; // y=854 slot check
const no8y = no7y + no7h + 14, no8h = 80; // y=950 move appointment
const no9y = no8y + no8h + 14, no9h = 54; // y=1044 patient thanks
const totalH = Math.max(yes2y + yes2h, no9y + no9h) + 28;
return ();
}
// ─── New Patient flow diagram (updated) ──────────────────────────────────────
function NewPatientFlow() {
// ── Canvas ───────────────────────────────────────────────────────────────
const W = 900;
const cx = 450;
// ── Coordinates ──────────────────────────────────────────────────────────
const nW = 240;
const nx = cx - nW / 2;
// Top sequence
const n1y = 14;
const n1h = 84;
const n2y = n1y + n1h + 14;
const n2h = 52;
const forkHY = n2y + n2h + 18;
// Main branch centres
const lcx = 210; // New Patient
const rcx = 690; // Existing Patient
const brW = 210;
// ── NEW PATIENT layout ────────────────────────────────────────────────────
// insurance question
const npInsY = forkHY + 44;
const npInsH = 52;
// sub-fork 1: has-ins / no-ins
const npSf1Y = npInsY + npInsH + 18;
const lhcx = 140; // has insurance
const lncx = 325; // no insurance
// no-ins leaf
const npNoInsY = npSf1Y + 20;
const npNoInsH = 76;
const npNoInsW = 130;
// what kind?
const npWkY = npSf1Y + 20;
const npWkH = 52;
const npWkW = 192;
// sub-fork 2: masshealth / other
const npSf2Y = npWkY + npWkH + 18;
const mhcx = 82; // masshealth
const othcx = 210; // other
// other ins leaf
const npOthY = npSf2Y + 20;
const npOthH = 76;
const npOthW = 130;
// masshealth consent node
const npMhY = npSf2Y + 20;
const npMhH = 76;
const npMhW = 155;
// selenium
const npSelY = npMhY + npMhH + 18;
const npSelH = 66;
const npSelW = 148;
// active / inactive fork
const npResHY = npSelY + npSelH + 14;
const npActCx = 40;
const npInaCx = 140;
const npResY = npResHY + 22;
// active result
const npActH = 76;
const npActW = 110;
// inactive → other insurance?
const npInaH = 52;
const npInaW = 110;
// other-insurance-after-inactive
const npOins2Y = npResY + Math.max(npActH, npInaH) + 16;
const npOins2H = 52;
const npOins2W = 150;
// final: yes=transfer / no=contact info
const npFinalY = npOins2Y + npOins2H + 18;
const npTxCx = npInaCx - 44;
const npCiCx = npInaCx + 46;
const npTxH = 44;
const npTxW = 86;
const npCiH = 64;
const npCiW = 132;
const totalH_np = npFinalY + npCiH + 18;
// ── EXISTING PATIENT layout ───────────────────────────────────────────────
const epInsY = forkHY + 44;
const epInsH = 64;
const epInsW = 210;
const epSf1Y = epInsY + epInsH + 18;
const epSameCx = 638;
const epDiffCx = 760;
// no-same (transfer)
const epDiffY = epSf1Y + 20;
const epDiffH = 52;
const epDiffW = 130;
// same confirmed
const epSameY = epSf1Y + 20;
const epSameH = 52;
const epSameW = 192;
// sub-fork 2: masshealth / other
const epSf2Y = epSameY + epSameH + 18;
const epMhCx = 586;
const epOthCx = 706;
// other ins leaf
const epOthY = epSf2Y + 20;
const epOthH = 76;
const epOthW = 130;
// mh auto-check
const epMhY = epSf2Y + 20;
const epMhH = 62;
const epMhW = 175;
// selenium
const epSelY = epMhY + epMhH + 18;
const epSelH = 66;
const epSelW = 148;
// active / inactive
const epResHY = epSelY + epSelH + 14;
const epActCx = 534;
const epInaCx = 648;
const epResY = epResHY + 22;
const epActH = 76;
const epActW = 110;
const epInaH = 64;
const epInaW = 120;
const totalH_ep = epResY + Math.max(epActH, epInaH) + 18;
const totalH = Math.max(totalH_np, totalH_ep) + 10;
return ();
}
// ─── Users AI Chat (internal staff assistant) config ─────────────────────────
const INTERNAL_CHAT_PLACEHOLDER = `Examples of custom context you can add:
• "This office primarily treats MassHealth patients — always prefer MassHealth eligibility checks."
• "When a patient name is typed, also show their upcoming appointment if available."
• "Default provider is Mary Scannell."`;
function InternalChatSettingsCard() {
const { toast } = useToast();
const [systemPrompt, setSystemPrompt] = useState("");
const initialized = useRef(false);
const { data, isLoading } = useQuery({
queryKey: ["/api/ai/internal-chat-settings"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/internal-chat-settings");
if (!res.ok)
return { systemPrompt: "" };
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (data && !initialized.current) {
initialized.current = true;
setSystemPrompt(data.systemPrompt ?? "");
}
}, [data]);
const saveMutation = useMutation({
mutationFn: async (prompt) => {
const res = await apiRequest("PUT", "/api/ai/internal-chat-settings", { systemPrompt: prompt });
if (!res.ok)
throw new Error("Failed to save");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/internal-chat-settings"] });
toast({ title: "Users AI Chat settings saved" });
},
onError: () => {
toast({ title: "Error", description: "Failed to save settings.", variant: "destructive" });
},
});
return (
Users AI Chat
Configure the AI assistant available to staff via the{" "}
bot icon in the top-right corner.
Staff can type natural language commands such as{" "}
"check MARIA GONZALES"{" "}
to search for a patient and pre-fill their eligibility check, or{" "}
"open claims"{" "}
to navigate directly to a page.
{/* Built-in workflows */}
Built-in Workflows
{/* Eligibility by name */}
🏥
Eligibility by patient name
Looks up the patient in the database, resolves their insurance, and opens the eligibility page pre-filled.
{["check Maria Jesus", "verify insurance for John Smith"].map((ex) => ({ex}))}
{/* Eligibility by member ID */}
🔢
Eligibility by Member ID + DOB
Provide a member ID and date of birth. Insurance is resolved from the patient record, or from what you state in the message.
{["check masshealth for 100xxxx, 10/10/1988"].map((ex) => ({ex}))}
{/* Check & Claim */}
⚡
Check eligibility + claim procedures
Resolves the patient, maps procedure names to CDT codes using the fee schedule, and opens the eligibility page ready to check and claim. Procedure names or CDT codes both work.
{[
"check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning",
"check Maria Jesus and claim D0120 D1110",
].map((ex) => ({ex}))}
CDT codes are looked up from the fee schedule — no AI translation needed.
If insurance is unknown the assistant will ask which one to use.
{/* Navigation */}
🗺️
Navigation
Opens any page in the app.
{["open claims", "go to schedule", "find patient GONZALES"].map((ex) => ({ex}))}
Optional instructions injected into the AI's system prompt. Use this to describe your
practice's preferences, common shortcuts, or patient demographics.
Map your own shorthand phrases to CDT codes. These override the built-in aliases when
staff type procedure names in the chat (e.g.{" "}
"check & claim perio exam").
Phrases are matched case-insensitively.
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function AiChatSettingsCard() {
const { toast } = useToast();
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
const initialized = useRef(false);
const [openPhoneReply, setOpenPhoneReply] = useState(false);
const { data: advancedSettings } = useQuery({
queryKey: ["/api/ai/advanced-settings"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/advanced-settings");
if (!res.ok)
return { openPhoneReply: false };
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (advancedSettings)
setOpenPhoneReply(advancedSettings.openPhoneReply ?? false);
}, [advancedSettings]);
const openPhoneReplyMutation = useMutation({
mutationFn: async (enabled) => {
const res = await apiRequest("PUT", "/api/ai/advanced-settings", { openPhoneReply: enabled });
if (!res.ok)
throw new Error("Failed to save");
return res.json();
},
onSuccess: (data) => {
setOpenPhoneReply(data.openPhoneReply);
queryClient.invalidateQueries({ queryKey: ["/api/ai/advanced-settings"] });
toast({ title: data.openPhoneReply ? "Open reply enabled" : "Open reply disabled" });
},
onError: () => {
toast({ title: "Error", description: "Failed to save setting.", variant: "destructive" });
},
});
// ── SMS template list ──────────────────────────────────────────
const [smsTemplates, setSmsTemplates] = useState([]);
const smsInitialized = useRef(false);
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: templates, isLoading } = useQuery({
queryKey: ["/api/ai/chat-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/chat-templates");
if (!res.ok)
throw new Error("Failed to load templates");
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
const { data: smsTemplateListData, isLoading: smsLoading } = useQuery({
queryKey: ["/api/twilio/sms-template-list"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/twilio/sms-template-list");
if (!res.ok)
return [];
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
// Seed AI chat templates
useEffect(() => {
if (templates && !initialized.current) {
initialized.current = true;
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
}
}, [templates]);
// Seed SMS template list — fall back to the saved reminderSms if list is empty
useEffect(() => {
if (smsTemplateListData && !smsInitialized.current) {
smsInitialized.current = true;
if (smsTemplateListData.length > 0) {
setSmsTemplates(smsTemplateListData);
}
else {
setSmsTemplates(DEFAULT_SMS_TEMPLATES);
}
}
}, [smsTemplateListData, templates]);
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/ai/chat-templates", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save templates");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/chat-templates"] });
toast({ title: "Templates saved", description: "AI chat templates have been updated." });
},
onError: (err) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const saveSmsListMutation = useMutation({
mutationFn: async (list) => {
const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save SMS templates");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/twilio/sms-template-list"] });
toast({ title: "SMS template saved" });
},
onError: (err) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const handleSubmit = (e) => {
e.preventDefault();
saveMutation.mutate({
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
});
};
const updateSmsTemplate = (idx, field, value) => {
setSmsTemplates((prev) => prev.map((t, i) => i === idx ? { ...t, [field]: value } : t));
};
const deleteSmsTemplate = (idx) => {
const updated = smsTemplates.filter((_, i) => i !== idx);
setSmsTemplates(updated);
saveSmsListMutation.mutate(updated);
};
const addSmsTemplate = () => {
setSmsTemplates((prev) => [...prev, { id: newId(), name: "", body: "" }]);
};
const officeName = officeContact?.officeName?.trim() || "";
const templateFields = [
{
key: "reminder",
icon: ,
label: "Appointment Reminder Reply",
description: "Sent when the AI first introduces itself after an appointment reminder.",
value: reminderGreeting,
onChange: setReminderGreeting,
placeholder: DEFAULTS.reminderGreeting,
},
{
key: "newPatient",
icon: ,
label: "New Patient Greeting",
description: "Sent when a new patient texts in for the first time.",
value: newPatientGreeting,
onChange: setNewPatientGreeting,
placeholder: DEFAULTS.newPatientGreeting,
},
{
key: "general",
icon: ,
label: "General Fallback",
description: "Used when the AI cannot determine the context of the patient's message.",
value: generalFallback,
onChange: setGeneralFallback,
placeholder: DEFAULTS.generalFallback,
},
];
return (
{/* ── Advanced: Open Phone Reply toggle ───────────────────── */}
Respond to any phone number at any time
When enabled, the AI will reply to all incoming texts — including unknown numbers not yet in your patient list — 24/7 regardless of office hours. When disabled, the AI only responds during after-hours.
Customize how the AI assistant introduces itself to patients. Use{" "}
{"{officeName}"}{" "}
as a placeholder — it will be replaced with your dental office name automatically.
{officeName && (
{"{officeName}"} will display as{" "}
"{officeName}"