Files
DentalManagementMH07/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx
T
ffandClaude Sonnet 5 0cd095330a fix: AI recall-call flow, speech-to-text time parsing, and stale conversation state
- Reset conversation stage to a fresh start on every new voice call
  (recall and reminder alike), and on every hangup/timeout/goodbye,
  so an abandoned or completed call never leaves a patient stuck
  mid-flow for their next call or text.
- Add a staleness check to the SMS flow: after a long idle gap,
  check whether the patient's reply still reads as a continuation
  before resuming the stored stage, falling back to a friendly
  "it's been a while" re-greeting when unsure.
- Fix parseTime() to handle Twilio's speech-to-text output, which
  renders "pm"/"am" with periods and often drops the colon in a
  spoken time (e.g. "100 p.m." -> 1:00 PM) — this was silently
  failing and causing the AI to loop asking for a time it had
  already been given.
- Extend the voice Gather timeout so the AI doesn't hang up on a
  patient who just needs a few seconds to respond.
- Reword the recall-call opening line and rework the post-"yes"
  prompt to ask for day and time together.
- Fix rclone WebDAV server to serve only the backups folder instead
  of the entire backend app directory (was exposing .env and source).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:35:48 -04:00

1686 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, PhoneCall, Stethoscope } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
// ─── Types ────────────────────────────────────────────────────────────────────
type SmsTemplate = { id: string; name: string; body: string };
type AiChatTemplates = {
reminderGreeting: string;
newPatientGreeting: string;
generalFallback: string;
};
type AiCallTemplates = {
greeting: string;
reminderGreeting: string;
newPatientGreeting: string;
generalFallback: string;
recallGreeting: string;
};
type OfficeContact = {
officeName?: string | null;
};
// ─── 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 CALL_DEFAULTS = {
greeting:
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?",
reminderGreeting: "Sure, I can help with that.",
newPatientGreeting: "No problem, let's get you set up.",
generalFallback: "Sure, how can I help?",
recallGreeting:
"Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning. Would you like to make an appointment because your insurance covers it?",
};
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: string, officeName: string) {
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
// ── Triple entry nodes ─────────────────────────────────────────────────────
const entryW = 150;
const lEntryCx = 130;
const mEntryCx = cx; // 350
const rEntryCx = 570;
const e1y = 14, e1h = 70;
// Merge connector (horizontal line where all three entries meet)
const mergeY = e1y + e1h + 14; // 98
// ── Center sequence ───────────────────────────────────────────────────────
const n2y = mergeY + 14, n2h = 52; // Patient replies
const n3y = n2y + n2h + 14, n3h = 98; // AI classifies
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 = 102; // 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 (
<svg
viewBox={`0 0 ${W} ${totalH}`}
className="w-full max-w-2xl mx-auto"
role="img"
aria-label="LangGraph Reminder, Recall, and Reschedule conversation flow"
>
<defs>
<marker id="rg2ah" markerWidth="10" markerHeight="7"
refX="9" refY="3.5" orient="auto" markerUnits="userSpaceOnUse">
<polygon points="0 0,10 3.5,0 7" fill="#9CA3AF" />
</marker>
<marker id="rg2fail" markerWidth="8" markerHeight="6"
refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
<polygon points="0 0,8 3,0 6" fill="#EF4444" />
</marker>
<marker id="rg2short" markerWidth="8" markerHeight="6"
refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
<polygon points="0 0,8 3,0 6" fill="#047857" />
</marker>
</defs>
{/* ══ TRIPLE ENTRY NODES ════════════════════════════════════════════ */}
{/* Left: Reminder SMS */}
<rect x={lEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
<text x={lEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Office sends Reminder</text>
<text x={lEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">SMS or Call</text>
<text x={lEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#93C5FD">Staff triggers batch send</text>
{/* Middle: Recall call/SMS for an existing patient */}
<rect x={mEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
<text x={mEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#5B21B6">Office call or SMS for</text>
<text x={mEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#5B21B6">an existing patient</text>
<text x={mEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#C4B5FD">Recall: free exam &amp; cleaning</text>
{/* Right: Reschedule by Office SMS */}
<rect x={rEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={rEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Office sends Reschedule</text>
<text x={rEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">SMS</text>
<text x={rEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#FDBA74">"Reschedule by Office" template</text>
{/* Converging lines → horizontal merge → Patient replies */}
<line x1={lEntryCx} y1={e1y+e1h} x2={lEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={mEntryCx} y1={e1y+e1h} x2={mEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={rEntryCx} y1={e1y+e1h} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lEntryCx} y1={mergeY} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={cx} y1={mergeY} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* ══ CENTER SEQUENCE ══════════════════════════════════════════════ */}
{/* N2: Patient replies */}
<rect x={nx} y={n2y} width={nW} height={n2h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
<text x={cx} y={n2y+22} textAnchor="middle" fontSize={13} fontWeight="600" fill="#374151">Patient replies</text>
<text x={cx} y={n2y+40} textAnchor="middle" fontSize={10} fill="#9CA3AF">Any SMS triggers the AI</text>
<line x1={cx} y1={n2y+n2h} x2={cx} y2={n3y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* N3: AI classifies */}
<rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={cx} y={n3y+18} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text>
<text x={cx} y={n3y+33} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Reminder: intro (MSG 1) + reply (MSG 2)</text>
<text x={cx} y={n3y+48} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: reply sent directly</text>
<text x={cx} y={n3y+63} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: same as Reschedule flow</text>
<text x={cx} y={n3y+82} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Date in reply → AI skips to Day Check</text>
{/* Fork lines */}
<line x1={cx} y1={n3y+n3h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lcx} y1={forkHY} x2={rcx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lcx} y1={forkHY} x2={lcx} y2={yes1y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
<line x1={rcx} y1={forkHY} x2={rcx} y2={no1y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* YES / NO badges */}
<rect x={lcx-26} y={forkHY-12} width={52} height={24} rx={12} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={lcx} y={forkHY+5} textAnchor="middle" fontSize={11} fontWeight="700" fill="#15803D">YES</text>
<rect x={rcx-24} y={forkHY-12} width={48} height={24} rx={12} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={rcx} y={forkHY+5} textAnchor="middle" fontSize={11} fontWeight="700" fill="#EA580C">NO</text>
{/* ══ YES BRANCH ══════════════════════════════════════════════════ */}
<rect x={lcx-yesW/2} y={yes1y} width={yesW} height={yes1h} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={lcx} y={yes1y+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Reminder: Thank you for confirming!</text>
<text x={lcx} y={yes1y+31} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day &amp; time?</text>
<text x={lcx} y={yes1y+46} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: What day &amp; time?</text>
<text x={lcx} y={yes1y+64} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date &amp; time]"</text>
<text x={lcx} y={yes1y+80} textAnchor="middle" fontSize={8} fill="#047857">date in reply → Day Check ↘</text>
<line x1={lcx} y1={yes1y+yes1h} x2={lcx} y2={yes2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* Yes2: Patient thanks */}
<rect x={lcx-yesW/2} y={yes2y} width={yesW} height={yes2h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
<text x={lcx} y={yes2y+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#374151">Patient: "Thank you / OK"</text>
<text x={lcx} y={yes2y+38} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">AI: "Thank you for choosing us! See you on [date]"</text>
{/* Dashed shortcut: YES with date → Day Check */}
<line
x1={lcx + yesW/2} y1={yes1y + 64}
x2={rcx - noW/2 - 2} y2={no2y + 34}
stroke="#047857" strokeWidth={1} strokeDasharray="4 3"
markerEnd="url(#rg2short)"
/>
{/* ══ NO / RESCHEDULE BRANCH ══════════════════════════════════════ */}
{/* no1: Understandable / ask date (with date-shortcut note) */}
<rect x={rcx-noW/2} y={no1y} width={noW} height={no1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={rcx} y={no1y+18} textAnchor="middle" fontSize={11} fontWeight="600" fill="#C2410C">MSG 2: It is understandable!</text>
<text x={rcx} y={no1y+34} textAnchor="middle" fontSize={11} fontWeight="600" fill="#C2410C">When would you like to reschedule?</text>
<text x={rcx} y={no1y+52} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Patient replies with a preferred date</text>
<text x={rcx} y={no1y+70} textAnchor="middle" fontSize={8} fill="#047857" fontStyle="italic">date in reply → skips to Day Check ↓</text>
<line x1={rcx} y1={no1y+no1h} x2={rcx} y2={no2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* no2: Day check */}
<rect x={rcx-noW/2} y={no2y} width={noW} height={no2h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={rcx} y={no2y+18} textAnchor="middle" fontSize={11} fontWeight="700" fill="#065F46">Check: Is office open on that day?</text>
<text x={rcx} y={no2y+34} textAnchor="middle" fontSize={9} fill="#6B7280">(reads Office Hours settings)</text>
<text x={rcx} y={no2y+52} textAnchor="middle" fontSize={9} fill="#15803D" fontWeight="600">✓ Open → ask what time</text>
<text x={rcx} y={no2y+68} textAnchor="middle" fontSize={9} fill="#DC2626" fontWeight="600">✗ Closed → "Office closed on [day]. Choose another?"</text>
<line x1={rcx+noW/2} y1={no2y+no2h-16} x2={failX-1} y2={no2y+no2h-16} stroke="#EF4444" strokeWidth={1} strokeDasharray="3 2" markerEnd="url(#rg2fail)" />
<rect x={failX} y={no2y+no2h-28} width={failW} height={24} rx={5} fill="#FEF2F2" stroke="#FCA5A5" strokeWidth={1} />
<text x={failX+failW/2} y={no2y+no2h-12} textAnchor="middle" fontSize={8} fill="#DC2626">↩ loops back to ask date</text>
<line x1={rcx} y1={no2y+no2h} x2={rcx} y2={no3y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* no3: What time on [date]? */}
<rect x={rcx-noW/2} y={no3y} width={noW} height={no3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={rcx} y={no3y+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#065F46">AI: "What time do you prefer</text>
<text x={rcx} y={no3y+36} textAnchor="middle" fontSize={11} fontWeight="600" fill="#065F46">on [date]?"</text>
<text x={rcx} y={no3y+52} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Patient replies with a time (e.g. "1 pm")</text>
<line x1={rcx} y1={no3y+no3h} x2={rcx} y2={no4y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* no4: Time check */}
<rect x={rcx-noW/2} y={no4y} width={noW} height={no4h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={rcx} y={no4y+18} textAnchor="middle" fontSize={11} fontWeight="700" fill="#065F46">Check: Is time within office hours?</text>
<text x={rcx} y={no4y+34} textAnchor="middle" fontSize={9} fill="#6B7280">(e.g. not during lunch 12–1pm)</text>
<text x={rcx} y={no4y+52} textAnchor="middle" fontSize={9} fill="#15803D" fontWeight="600">✓ In hours → ask to confirm</text>
<text x={rcx} y={no4y+68} textAnchor="middle" fontSize={9} fill="#DC2626" fontWeight="600">✗ → "Not available. Our hours: [AM]–[PM]. Other time?"</text>
<line x1={rcx+noW/2} y1={no4y+no4h-16} x2={failX-1} y2={no4y+no4h-16} stroke="#EF4444" strokeWidth={1} strokeDasharray="3 2" markerEnd="url(#rg2fail)" />
<rect x={failX} y={no4y+no4h-28} width={failW} height={24} rx={5} fill="#FEF2F2" stroke="#FCA5A5" strokeWidth={1} />
<text x={failX+failW/2} y={no4y+no4h-12} textAnchor="middle" fontSize={8} fill="#DC2626">↩ loops back to ask time</text>
<line x1={rcx} y1={no4y+no4h} x2={rcx} y2={no5y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* no5: Just to confirm */}
<rect x={rcx-noW/2} y={no5y} width={noW} height={no5h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={rcx} y={no5y+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#065F46">AI: "Just to confirm —</text>
<text x={rcx} y={no5y+36} textAnchor="middle" fontSize={11} fontWeight="600" fill="#065F46">do you prefer [date at time]?"</text>
<text x={rcx} y={no5y+52} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Patient replies YES or NO</text>
<line x1={rcx} y1={no5y+no5h} x2={rcx} y2={no6y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* no6: Patient YES/NO */}
<rect x={rcx-noW/2} y={no6y} width={noW} height={no6h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
<text x={rcx} y={no6y+18} textAnchor="middle" fontSize={11} fontWeight="600" fill="#374151">Patient confirms or corrects</text>
<text x={rcx} y={no6y+36} textAnchor="middle" fontSize={9} fill="#15803D" fontWeight="600">✓ YES → check slot availability</text>
<text x={rcx} y={no6y+52} textAnchor="middle" fontSize={9} fill="#DC2626" fontWeight="600">✗ NO → "No problem! What day/time?"</text>
<text x={rcx} y={no6y+66} textAnchor="middle" fontSize={8} fill="#9CA3AF" fontStyle="italic">↩ returns to date step</text>
<line x1={rcx} y1={no6y+no6h} x2={rcx} y2={no7y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* no7: Slot availability check */}
<rect x={rcx-noW/2} y={no7y} width={noW} height={no7h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={rcx} y={no7y+18} textAnchor="middle" fontSize={11} fontWeight="700" fill="#065F46">Check: Is slot available for staff?</text>
<text x={rcx} y={no7y+34} textAnchor="middle" fontSize={9} fill="#6B7280">(no overlapping appointments)</text>
<text x={rcx} y={no7y+52} textAnchor="middle" fontSize={9} fill="#15803D" fontWeight="600">✓ Free → move appointment</text>
<text x={rcx} y={no7y+68} textAnchor="middle" fontSize={9} fill="#DC2626" fontWeight="600">✗ Taken → "[date] not available. Other time?"</text>
<line x1={rcx+noW/2} y1={no7y+no7h-16} x2={failX-1} y2={no7y+no7h-16} stroke="#EF4444" strokeWidth={1} strokeDasharray="3 2" markerEnd="url(#rg2fail)" />
<rect x={failX} y={no7y+no7h-28} width={failW} height={24} rx={5} fill="#FEF2F2" stroke="#FCA5A5" strokeWidth={1} />
<text x={failX+failW/2} y={no7y+no7h-12} textAnchor="middle" fontSize={8} fill="#DC2626">↩ loops back to ask date</text>
<line x1={rcx} y1={no7y+no7h} x2={rcx} y2={no8y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* no8: DB move + AI badge */}
<rect x={rcx-noW/2} y={no8y} width={noW} height={no8h} rx={8}
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
<rect x={rcx-46} y={no8y-11} width={92} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
<text x={rcx} y={no8y+3} textAnchor="middle" fontSize={9} fontWeight="700" fill="#0369A1">DB Update</text>
<text x={rcx} y={no8y+22} textAnchor="middle" fontSize={11} fontWeight="600" fill="#0369A1">Appointment moved!</text>
<text x={rcx} y={no8y+38} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Appt moved to [date at time].</text>
<text x={rcx} y={no8y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Receptionist will confirm tomorrow."</text>
<text x={rcx} y={no8y+68} textAnchor="middle" fontSize={9} fill="#0369A1" fontWeight="600">🤖 AI badge shown on schedule grid</text>
<line x1={rcx} y1={no8y+no8h} x2={rcx} y2={no9y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* no9: Patient thanks */}
<rect x={rcx-noW/2} y={no9y} width={noW} height={no9h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
<text x={rcx} y={no9y+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#374151">Patient: "Thank you / OK"</text>
<text x={rcx} y={no9y+38} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">AI: "Thank you for choosing us! See you on [date]"</text>
</svg>
);
}
// ─── 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 (
<svg
viewBox={`0 0 ${W} ${totalH}`}
className="w-full max-w-3xl mx-auto"
role="img"
aria-label="New patient AI conversation flow diagram"
>
<defs>
<marker id="nph2" markerWidth="10" markerHeight="7"
refX="9" refY="3.5" orient="auto" markerUnits="userSpaceOnUse">
<polygon points="0 0,10 3.5,0 7" fill="#9CA3AF" />
</marker>
</defs>
{/* ═══════════ ENTRY — 3-message flow ═════════════════════════ */}
<rect x={nx} y={n1y} width={nW} height={n1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
<text x={cx} y={n1y+18} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Patient texts the office number</text>
<text x={cx} y={n1y+34} textAnchor="middle" fontSize={10} fontWeight="600" fill="#3B82F6">AI sends 3 messages (TwiML, guaranteed order):</text>
<text x={cx} y={n1y+50} textAnchor="middle" fontSize={9} fill="#6B7280">① Self-intro (New Patient Greeting template)</text>
<text x={cx} y={n1y+64} textAnchor="middle" fontSize={9} fill="#6B7280">② Empathetic ack · ③ "New or existing patient?"</text>
<line x1={cx} y1={n1y+n1h} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={nx} y={n2y} width={nW} height={n2h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={cx} y={n2y+20} textAnchor="middle" fontSize={12} fontWeight="600" fill="#065F46">AI classifies patient reply</text>
<text x={cx} y={n2y+38} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"new patient" / "existing" / "old patient" / …</text>
{/* ═══════════ MAIN FORK ══════════════════════════════════════ */}
<line x1={cx} y1={n2y+n2h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lcx} y1={forkHY} x2={rcx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lcx} y1={forkHY} x2={lcx} y2={npInsY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<line x1={rcx} y1={forkHY} x2={rcx} y2={epInsY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={lcx-44} y={forkHY-13} width={88} height={24} rx={12} fill="#EEF2FF" stroke="#6366F1" strokeWidth={1.5} />
<text x={lcx} y={forkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#4338CA">New Patient</text>
<rect x={rcx-52} y={forkHY-13} width={104} height={24} rx={12} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
<text x={rcx} y={forkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#6D28D9">Existing Patient</text>
{/* ═══════════ NEW PATIENT BRANCH ═════════════════════════════ */}
{/* Insurance? */}
<rect x={lcx-brW/2} y={npInsY} width={brW} height={npInsH} rx={8} fill="#EEF2FF" stroke="#6366F1" strokeWidth={1.5} />
<text x={lcx} y={npInsY+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#3730A3">Do you have dental insurance?</text>
<text x={lcx} y={npInsY+38} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Yes" / "No" / "MassHealth" / …</text>
{/* Sub-fork 1 */}
<line x1={lcx} y1={npInsY+npInsH} x2={lcx} y2={npSf1Y} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lhcx} y1={npSf1Y} x2={lncx} y2={npSf1Y} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lhcx} y1={npSf1Y} x2={lhcx} y2={npWkY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<line x1={lncx} y1={npSf1Y} x2={lncx} y2={npNoInsY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={lhcx-20} y={npSf1Y-11} width={40} height={22} rx={11} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={lhcx} y={npSf1Y+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#065F46">Yes</text>
<rect x={lncx-24} y={npSf1Y-11} width={48} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={lncx} y={npSf1Y+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">No ins.</text>
{/* No insurance → schedule */}
<rect x={lncx-npNoInsW/2} y={npNoInsY} width={npNoInsW} height={npNoInsH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={lncx} y={npNoInsY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">No insurance</text>
<text x={lncx} y={npNoInsY+32} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Ask preferred date &amp; time</text>
<text x={lncx} y={npNoInsY+48} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Office hours check</text>
<text x={lncx} y={npNoInsY+64} textAnchor="middle" fontSize={9} fill="#047857" fontWeight="600">→ Appointment created</text>
{/* What kind? */}
<rect x={lhcx-npWkW/2} y={npWkY} width={npWkW} height={npWkH} rx={8} fill="#EEF2FF" stroke="#6366F1" strokeWidth={1.5} />
<text x={lhcx} y={npWkY+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#3730A3">What kind of insurance?</text>
<text x={lhcx} y={npWkY+38} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"MassHealth" / "Blue Cross" / …</text>
{/* Sub-fork 2 */}
<line x1={lhcx} y1={npWkY+npWkH} x2={lhcx} y2={npSf2Y} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={mhcx} y1={npSf2Y} x2={othcx} y2={npSf2Y} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={mhcx} y1={npSf2Y} x2={mhcx} y2={npMhY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<line x1={othcx} y1={npSf2Y} x2={othcx} y2={npOthY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={mhcx-36} y={npSf2Y-11} width={72} height={22} rx={11} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={mhcx} y={npSf2Y+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#065F46">MassHealth</text>
<rect x={othcx-28} y={npSf2Y-11} width={56} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
<text x={othcx} y={npSf2Y+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Other</text>
{/* Other ins → schedule */}
<rect x={othcx-npOthW/2} y={npOthY} width={npOthW} height={npOthH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={othcx} y={npOthY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Other insurance</text>
<text x={othcx} y={npOthY+32} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Ask preferred date &amp; time</text>
<text x={othcx} y={npOthY+48} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Office hours check</text>
<text x={othcx} y={npOthY+64} textAnchor="middle" fontSize={9} fill="#047857" fontWeight="600">→ Appointment created</text>
{/* MassHealth consent + ID+DOB */}
<rect x={mhcx-npMhW/2} y={npMhY} width={npMhW} height={npMhH} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={mhcx} y={npMhY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">Check MassHealth now?</text>
<text x={mhcx} y={npMhY+30} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Yes → send Member ID</text>
<text x={mhcx} y={npMhY+44} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">+ DOB (MM/DD/YYYY)</text>
<text x={mhcx} y={npMhY+62} textAnchor="middle" fontSize={8} fill="#9CA3AF" fontStyle="italic">No → ask date preference</text>
{/* NP Selenium */}
<line x1={mhcx} y1={npMhY+npMhH} x2={mhcx} y2={npSelY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={mhcx-npSelW/2} y={npSelY} width={npSelW} height={npSelH} rx={8}
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
<rect x={mhcx-38} y={npSelY-11} width={76} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
<text x={mhcx} y={npSelY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Selenium</text>
<text x={mhcx} y={npSelY+20} textAnchor="middle" fontSize={10} fontWeight="600" fill="#0369A1">MassHealth Portal</text>
<text x={mhcx} y={npSelY+36} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Auto-checks eligibility</text>
<text x={mhcx} y={npSelY+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Result sent via SMS</text>
{/* NP ACTIVE / INACTIVE fork */}
<line x1={mhcx} y1={npSelY+npSelH} x2={mhcx} y2={npResHY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={npActCx} y1={npResHY} x2={npInaCx} y2={npResHY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={npActCx} y1={npResHY} x2={npActCx} y2={npResY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<line x1={npInaCx} y1={npResHY} x2={npInaCx} y2={npResY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={npActCx-28} y={npResHY-11} width={56} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={npActCx} y={npResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">ACTIVE</text>
<rect x={npInaCx-34} y={npResHY-11} width={68} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={npInaCx} y={npResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">INACTIVE</text>
{/* NP ACTIVE result */}
<rect x={npActCx-npActW/2} y={npResY} width={npActW} height={npActH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={npActCx} y={npResY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">MassHealth active!</text>
<text x={npActCx} y={npResY+30} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Ask date &amp; time</text>
<text x={npActCx} y={npResY+46} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Office hours check</text>
<text x={npActCx} y={npResY+62} textAnchor="middle" fontSize={9} fill="#047857" fontWeight="600">→ Appt created</text>
{/* NP INACTIVE → other insurance? */}
<rect x={npInaCx-npInaW/2} y={npResY} width={npInaW} height={npInaH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={npInaCx} y={npResY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Coverage inactive</text>
<text x={npInaCx} y={npResY+36} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">or not verified</text>
<line x1={npInaCx} y1={npResY+npInaH} x2={npInaCx} y2={npOins2Y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={npInaCx-npOins2W/2} y={npOins2Y} width={npOins2W} height={npOins2H} rx={8} fill="#FEF9C3" stroke="#CA8A04" strokeWidth={1.5} />
<text x={npInaCx} y={npOins2Y+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#92400E">Other insurance?</text>
<text x={npInaCx} y={npOins2Y+36} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Yes" or "No"</text>
{/* Final fork: yes=transfer, no=contact info */}
<line x1={npInaCx} y1={npOins2Y+npOins2H} x2={npInaCx} y2={npFinalY-16} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={npTxCx} y1={npFinalY-16} x2={npCiCx} y2={npFinalY-16} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={npTxCx} y1={npFinalY-16} x2={npTxCx} y2={npFinalY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<line x1={npCiCx} y1={npFinalY-16} x2={npCiCx} y2={npFinalY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={npTxCx-22} y={npFinalY-28} width={44} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={npTxCx} y={npFinalY-13} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">Yes</text>
<rect x={npCiCx-22} y={npFinalY-28} width={44} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={npCiCx} y={npFinalY-13} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">No</text>
<rect x={npTxCx-npTxW/2} y={npFinalY} width={npTxW} height={npTxH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={npTxCx} y={npFinalY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Transfer</text>
<text x={npTxCx} y={npFinalY+32} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">to staff</text>
<rect x={npCiCx-npCiW/2} y={npFinalY} width={npCiW} height={npCiH} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
<text x={npCiCx} y={npFinalY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#374151">Leave name &amp; phone</text>
<text x={npCiCx} y={npFinalY+32} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Receptionist will</text>
<text x={npCiCx} y={npFinalY+48} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">contact you shortly</text>
{/* ═══════════ EXISTING PATIENT BRANCH ════════════════════════ */}
{/* Same insurance? */}
<rect x={rcx-epInsW/2} y={epInsY} width={epInsW} height={epInsH} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
<text x={rcx} y={epInsY+18} textAnchor="middle" fontSize={11} fontWeight="600" fill="#5B21B6">Got it — existing patient!</text>
<text x={rcx} y={epInsY+34} textAnchor="middle" fontSize={11} fontWeight="600" fill="#5B21B6">Same insurance on file?</text>
<text x={rcx} y={epInsY+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Yes" / "No" / "Changed"</text>
{/* Sub-fork 1 */}
<line x1={rcx} y1={epInsY+epInsH} x2={rcx} y2={epSf1Y} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={epSameCx} y1={epSf1Y} x2={epDiffCx} y2={epSf1Y} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={epSameCx} y1={epSf1Y} x2={epSameCx} y2={epSameY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<line x1={epDiffCx} y1={epSf1Y} x2={epDiffCx} y2={epDiffY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={epSameCx-22} y={epSf1Y-11} width={44} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={epSameCx} y={epSf1Y+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">YES</text>
<rect x={epDiffCx-22} y={epSf1Y-11} width={44} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={epDiffCx} y={epSf1Y+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">NO</text>
{/* Changed → transfer */}
<rect x={epDiffCx-epDiffW/2} y={epDiffY} width={epDiffW} height={epDiffH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={epDiffCx} y={epDiffY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Insurance changed</text>
<text x={epDiffCx} y={epDiffY+36} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Transfer to staff</text>
{/* Same confirmed */}
<rect x={epSameCx-epSameW/2} y={epSameY} width={epSameW} height={epSameH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={epSameCx} y={epSameY+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#15803D">Insurance confirmed</text>
<text x={epSameCx} y={epSameY+38} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">MassHealth or Other?</text>
{/* Sub-fork 2 */}
<line x1={epSameCx} y1={epSameY+epSameH} x2={epSameCx} y2={epSf2Y} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={epMhCx} y1={epSf2Y} x2={epOthCx} y2={epSf2Y} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={epMhCx} y1={epSf2Y} x2={epMhCx} y2={epMhY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<line x1={epOthCx} y1={epSf2Y} x2={epOthCx} y2={epOthY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={epMhCx-36} y={epSf2Y-11} width={72} height={22} rx={11} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={epMhCx} y={epSf2Y+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#065F46">MassHealth</text>
<rect x={epOthCx-28} y={epSf2Y-11} width={56} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
<text x={epOthCx} y={epSf2Y+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Other</text>
{/* Other ins → schedule */}
<rect x={epOthCx-epOthW/2} y={epOthY} width={epOthW} height={epOthH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={epOthCx} y={epOthY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Other insurance</text>
<text x={epOthCx} y={epOthY+32} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Ask preferred date &amp; time</text>
<text x={epOthCx} y={epOthY+48} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Office hours check</text>
<text x={epOthCx} y={epOthY+64} textAnchor="middle" fontSize={9} fill="#047857" fontWeight="600">→ Appointment created</text>
{/* EP MassHealth auto-check */}
<rect x={epMhCx-epMhW/2} y={epMhY} width={epMhW} height={epMhH} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={epMhCx} y={epMhY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">MassHealth auto-check</text>
<text x={epMhCx} y={epMhY+34} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Using stored ID + DOB</text>
<text x={epMhCx} y={epMhY+50} textAnchor="middle" fontSize={8} fill="#9CA3AF" fontStyle="italic">Please wait 30-60 secs…</text>
{/* EP Selenium */}
<line x1={epMhCx} y1={epMhY+epMhH} x2={epMhCx} y2={epSelY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={epMhCx-epSelW/2} y={epSelY} width={epSelW} height={epSelH} rx={8}
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
<rect x={epMhCx-38} y={epSelY-11} width={76} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
<text x={epMhCx} y={epSelY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Selenium</text>
<text x={epMhCx} y={epSelY+20} textAnchor="middle" fontSize={10} fontWeight="600" fill="#0369A1">MassHealth Portal</text>
<text x={epMhCx} y={epSelY+36} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Auto-checks eligibility</text>
<text x={epMhCx} y={epSelY+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Result sent via SMS</text>
{/* EP ACTIVE / INACTIVE */}
<line x1={epMhCx} y1={epSelY+epSelH} x2={epMhCx} y2={epResHY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={epActCx} y1={epResHY} x2={epInaCx} y2={epResHY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={epActCx} y1={epResHY} x2={epActCx} y2={epResY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<line x1={epInaCx} y1={epResHY} x2={epInaCx} y2={epResY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph2)" />
<rect x={epActCx-28} y={epResHY-11} width={56} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={epActCx} y={epResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">ACTIVE</text>
<rect x={epInaCx-34} y={epResHY-11} width={68} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={epInaCx} y={epResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">INACTIVE</text>
<rect x={epActCx-epActW/2} y={epResY} width={epActW} height={epActH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={epActCx} y={epResY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Coverage active!</text>
<text x={epActCx} y={epResY+30} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Ask date &amp; time</text>
<text x={epActCx} y={epResY+46} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Office hours check</text>
<text x={epActCx} y={epResY+62} textAnchor="middle" fontSize={9} fill="#047857" fontWeight="600">→ Appt created</text>
<rect x={epInaCx-epInaW/2} y={epResY} width={epInaW} height={epInaH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={epInaCx} y={epResY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Coverage inactive</text>
<text x={epInaCx} y={epResY+34} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Self-pay offer?</text>
<text x={epInaCx} y={epResY+50} textAnchor="middle" fontSize={8} fill="#9CA3AF" fontStyle="italic">YES → schedule / NO → close</text>
</svg>
);
}
// ─── 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<{ systemPrompt: string }>({
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: string) => {
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 (
<Card>
<CardContent className="py-6 space-y-5">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Users AI Chat</h3>
</div>
<p className="text-sm text-muted-foreground">
Configure the AI assistant available to staff via the{" "}
<span className="font-medium text-foreground">bot icon</span> in the top-right corner.
Staff can type natural language commands such as{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">"check MARIA GONZALES"</code>{" "}
to search for a patient and pre-fill their eligibility check, or{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">"open claims"</code>{" "}
to navigate directly to a page.
</p>
{/* Built-in workflows */}
<div className="space-y-2">
<p className="text-xs font-semibold text-foreground uppercase tracking-wide">Built-in Workflows</p>
<div className="divide-y rounded-lg border overflow-hidden text-xs">
{/* Eligibility by name */}
<div className="flex items-start gap-3 p-3 bg-background">
<span className="mt-0.5 shrink-0 text-base">🏥</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Eligibility by patient name</p>
<p className="text-muted-foreground">
Looks up the patient in the database, resolves their insurance, and opens the eligibility page pre-filled.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{["check Maria Jesus", "verify insurance for John Smith"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
{/* Eligibility by member ID */}
<div className="flex items-start gap-3 p-3 bg-muted/20">
<span className="mt-0.5 shrink-0 text-base">🔢</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Eligibility by Member ID + DOB</p>
<p className="text-muted-foreground">
Provide a member ID and date of birth. Insurance is resolved from the patient record, or from what you state in the message.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{["check masshealth for 100xxxx, 10/10/1988"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
{/* Check & Claim */}
<div className="flex items-start gap-3 p-3 bg-background">
<span className="mt-0.5 shrink-0 text-base">⚡</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Check eligibility + claim procedures</p>
<p className="text-muted-foreground">
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.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{[
"check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning",
"check Maria Jesus and claim D0120 D1110",
].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono block">{ex}</code>
))}
</div>
<p className="text-muted-foreground pt-1">
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.
</p>
</div>
</div>
{/* Navigation */}
<div className="flex items-start gap-3 p-3 bg-muted/20">
<span className="mt-0.5 shrink-0 text-base">🗺️</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Navigation</p>
<p className="text-muted-foreground">Opens any page in the app.</p>
<div className="flex flex-wrap gap-1 pt-1">
{["open claims", "go to schedule", "find patient GONZALES"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
</div>
</div>
{/* CDT Aliases */}
<CdtAliasesCard />
{/* Additional context / system prompt */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Additional AI Context</span>
</div>
<p className="text-xs text-muted-foreground">
Optional instructions injected into the AI's system prompt. Use this to describe your
practice's preferences, common shortcuts, or patient demographics.
</p>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder={INTERNAL_CHAT_PLACEHOLDER}
rows={5}
className="text-sm resize-none"
/>
)}
</div>
<div className="flex items-center gap-3 pt-1">
<Button
disabled={saveMutation.isPending || isLoading}
className="bg-teal-600 hover:bg-teal-700 text-white"
onClick={() => saveMutation.mutate(systemPrompt)}
>
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
{systemPrompt && (
<Button
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => setSystemPrompt("")}
>
Clear
</Button>
)}
</div>
</CardContent>
</Card>
);
}
// ─── CDT Aliases card ────────────────────────────────────────────────────────
const BUILTIN_ALIASES = [
{ phrase: "perio exam", cdtCode: "D0120" },
{ phrase: "periodic exam", cdtCode: "D0120" },
{ phrase: "adult cleaning", cdtCode: "D1110" },
{ phrase: "adult prophy", cdtCode: "D1110" },
{ phrase: "child cleaning", cdtCode: "D1120" },
{ phrase: "full mouth xray", cdtCode: "D0210" },
{ phrase: "fmx", cdtCode: "D0210" },
{ phrase: "pano", cdtCode: "D0330" },
{ phrase: "comp exam", cdtCode: "D0150" },
{ phrase: "limited exam", cdtCode: "D0140" },
{ phrase: "scaling root planing", cdtCode: "D4341" },
{ phrase: "srp", cdtCode: "D4341" },
{ phrase: "perio maintenance", cdtCode: "D4910" },
];
type CdtAlias = { phrase: string; cdtCode: string };
function CdtAliasesCard() {
const { toast } = useToast();
const [aliases, setAliases] = useState<CdtAlias[]>([]);
const [newPhrase, setNewPhrase] = useState("");
const [newCode, setNewCode] = useState("");
const [showBuiltin, setShowBuiltin] = useState(false);
const initialized = useRef(false);
const { data, isLoading } = useQuery<CdtAlias[]>({
queryKey: ["/api/ai/cdt-aliases"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/cdt-aliases");
if (!res.ok) return [];
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (data && !initialized.current) {
initialized.current = true;
setAliases(data);
}
}, [data]);
const saveMutation = useMutation({
mutationFn: async (list: CdtAlias[]) => {
const res = await apiRequest("PUT", "/api/ai/cdt-aliases", list);
if (!res.ok) throw new Error("Failed to save");
return res.json();
},
onSuccess: (saved: CdtAlias[]) => {
setAliases(saved);
queryClient.invalidateQueries({ queryKey: ["/api/ai/cdt-aliases"] });
toast({ title: "CDT aliases saved" });
},
onError: () => {
toast({ title: "Error", description: "Failed to save aliases.", variant: "destructive" });
},
});
const handleAdd = () => {
const phrase = newPhrase.trim().toLowerCase();
const code = newCode.trim().toUpperCase();
if (!phrase || !code) return;
if (aliases.some((a) => a.phrase === phrase)) {
toast({ title: "Duplicate", description: `"${phrase}" already exists.`, variant: "destructive" });
return;
}
const updated = [...aliases, { phrase, cdtCode: code }];
setAliases(updated);
saveMutation.mutate(updated);
setNewPhrase("");
setNewCode("");
};
const handleDelete = (idx: number) => {
const updated = aliases.filter((_, i) => i !== idx);
setAliases(updated);
saveMutation.mutate(updated);
};
return (
<Card>
<CardContent className="py-6 space-y-4">
<div className="flex items-center gap-2">
<BookMarked className="h-5 w-5 text-primary" />
<h3 className="text-base font-semibold">CDT Aliases</h3>
</div>
<p className="text-xs text-muted-foreground">
Map your own shorthand phrases to CDT codes. These override the built-in aliases when
staff type procedure names in the chat (e.g.{" "}
<code className="bg-muted px-1 py-0.5 rounded font-mono">"check &amp; claim perio exam"</code>).
Phrases are matched case-insensitively.
</p>
{/* Custom alias list */}
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : aliases.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No custom aliases yet — add one below.</p>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Phrase (what staff type)</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">CDT Code</th>
<th className="w-8" />
</tr>
</thead>
<tbody className="divide-y">
{aliases.map((a, i) => (
<tr key={i} className="bg-background">
<td className="px-3 py-2">
<code className="font-mono">{a.phrase}</code>
</td>
<td className="px-3 py-2 font-mono font-semibold text-primary">{a.cdtCode}</td>
<td className="px-2 py-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(i)}
disabled={saveMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add row */}
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<p className="text-xs text-muted-foreground">Phrase</p>
<Input
value={newPhrase}
onChange={(e) => setNewPhrase(e.target.value)}
placeholder='e.g. "cleaning adult"'
className="h-8 text-xs"
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
/>
</div>
<div className="w-28 space-y-1">
<p className="text-xs text-muted-foreground">CDT Code</p>
<Input
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder="D1110"
className="h-8 text-xs font-mono"
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
/>
</div>
<Button
size="sm"
className="h-8 gap-1.5 shrink-0"
onClick={handleAdd}
disabled={!newPhrase.trim() || !newCode.trim() || saveMutation.isPending}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{/* Built-in aliases reference (collapsible) */}
<div className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted/40 transition-colors"
onClick={() => setShowBuiltin((v) => !v)}
>
<span>View built-in aliases ({BUILTIN_ALIASES.length})</span>
{showBuiltin ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
{showBuiltin && (
<table className="w-full text-xs border-t">
<tbody className="divide-y">
{BUILTIN_ALIASES.map((a) => (
<tr key={a.phrase} className="bg-muted/20">
<td className="px-3 py-1.5">
<code className="font-mono text-muted-foreground">{a.phrase}</code>
</td>
<td className="px-3 py-1.5 font-mono font-semibold text-muted-foreground">{a.cdtCode}</td>
<td className="px-3 py-1.5 text-muted-foreground/60 italic text-[10px]">built-in</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</CardContent>
</Card>
);
}
// ─── 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 [callGreeting, setCallGreeting] = useState(CALL_DEFAULTS.greeting);
const [callReminderGreeting, setCallReminderGreeting] = useState(CALL_DEFAULTS.reminderGreeting);
const [callNewPatientGreeting, setCallNewPatientGreeting] = useState(CALL_DEFAULTS.newPatientGreeting);
const [callGeneralFallback, setCallGeneralFallback] = useState(CALL_DEFAULTS.generalFallback);
const [callRecallGreeting, setCallRecallGreeting] = useState(CALL_DEFAULTS.recallGreeting);
const callInitialized = useRef(false);
const [openPhoneReply, setOpenPhoneReply] = useState(false);
const { data: advancedSettings } = useQuery<{ openPhoneReply: boolean }>({
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: boolean) => {
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<SmsTemplate[]>([]);
const smsInitialized = useRef(false);
const { data: officeContact } = useQuery<OfficeContact | null>({
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<AiChatTemplates>({
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<SmsTemplate[]>({
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]);
const { data: callTemplatesData, isLoading: callTemplatesLoading } = useQuery<AiCallTemplates>({
queryKey: ["/api/ai/call-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/call-templates");
if (!res.ok) throw new Error("Failed to load call templates");
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
// Seed AI call templates
useEffect(() => {
if (callTemplatesData && !callInitialized.current) {
callInitialized.current = true;
setCallGreeting(callTemplatesData.greeting || CALL_DEFAULTS.greeting);
setCallReminderGreeting(callTemplatesData.reminderGreeting || CALL_DEFAULTS.reminderGreeting);
setCallNewPatientGreeting(callTemplatesData.newPatientGreeting || CALL_DEFAULTS.newPatientGreeting);
setCallGeneralFallback(callTemplatesData.generalFallback || CALL_DEFAULTS.generalFallback);
setCallRecallGreeting(callTemplatesData.recallGreeting || CALL_DEFAULTS.recallGreeting);
}
}, [callTemplatesData]);
// 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: AiChatTemplates) => {
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: any) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const saveCallMutation = useMutation({
mutationFn: async (data: AiCallTemplates) => {
const res = await apiRequest("PUT", "/api/ai/call-templates", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save call templates");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/call-templates"] });
toast({ title: "Templates saved", description: "AI call templates have been updated." });
},
onError: (err: any) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const saveSmsListMutation = useMutation({
mutationFn: async (list: SmsTemplate[]) => {
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: any) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate({
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
});
};
const handleCallSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveCallMutation.mutate({
greeting: callGreeting.trim() || CALL_DEFAULTS.greeting,
reminderGreeting: callReminderGreeting.trim() || CALL_DEFAULTS.reminderGreeting,
newPatientGreeting: callNewPatientGreeting.trim() || CALL_DEFAULTS.newPatientGreeting,
generalFallback: callGeneralFallback.trim() || CALL_DEFAULTS.generalFallback,
recallGreeting: callRecallGreeting.trim() || CALL_DEFAULTS.recallGreeting,
});
};
const updateSmsTemplate = (idx: number, field: "name" | "body", value: string) => {
setSmsTemplates((prev) => prev.map((t, i) => i === idx ? { ...t, [field]: value } : t));
};
const deleteSmsTemplate = (idx: number) => {
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: <CalendarCheck className="h-4 w-4 text-primary" />,
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: <UserPlus className="h-4 w-4 text-primary" />,
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: <MessageCircle className="h-4 w-4 text-primary" />,
label: "General Fallback",
description: "Used when the AI cannot determine the context of the patient's message.",
value: generalFallback,
onChange: setGeneralFallback,
placeholder: DEFAULTS.generalFallback,
},
];
const callTemplateFields = [
{
key: "callGreeting",
icon: <PhoneCall className="h-4 w-4 text-primary" />,
label: "Call an existing patient for an appointment",
description: "Lisa's opening line when placing an AI Call to a patient — use {firstName} and {officeName} as placeholders.",
value: callGreeting,
onChange: setCallGreeting,
placeholder: CALL_DEFAULTS.greeting,
},
{
key: "callRecall",
icon: <Stethoscope className="h-4 w-4 text-primary" />,
label: "Recall Call: Existing Patient Check-Up Offer",
description: "Lisa's opening line for a Patient Connection \"AI Call\" — offers an existing patient a routine exam & cleaning (most insurance covers it at no cost) and asks if they'd like to book. Use {firstName} and {officeName} as placeholders.",
value: callRecallGreeting,
onChange: setCallRecallGreeting,
placeholder: CALL_DEFAULTS.recallGreeting,
},
{
key: "callReminder",
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
label: "Reminder Call Reply",
description: "Spoken after the call's opening greeting when the AI is following up on an appointment reminder — keep it short, it's read aloud, not texted.",
value: callReminderGreeting,
onChange: setCallReminderGreeting,
placeholder: CALL_DEFAULTS.reminderGreeting,
},
{
key: "callNewPatient",
icon: <UserPlus className="h-4 w-4 text-primary" />,
label: "New Patient Call Reply",
description: "Spoken when the AI is walking a new patient through scheduling on a live call.",
value: callNewPatientGreeting,
onChange: setCallNewPatientGreeting,
placeholder: CALL_DEFAULTS.newPatientGreeting,
},
{
key: "callGeneral",
icon: <MessageCircle className="h-4 w-4 text-primary" />,
label: "General Call Fallback",
description: "Spoken when the AI cannot determine the context of what the patient said on a call.",
value: callGeneralFallback,
onChange: setCallGeneralFallback,
placeholder: CALL_DEFAULTS.generalFallback,
},
];
return (
<div className="space-y-6">
{/* ── Advanced: Open Phone Reply toggle ───────────────────── */}
<Card>
<CardContent className="py-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<Zap className="h-5 w-5 text-primary mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-semibold leading-snug">Respond to any phone number at any time</p>
<p className="text-xs text-muted-foreground mt-0.5">
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.
</p>
</div>
</div>
<Switch
checked={openPhoneReply}
disabled={openPhoneReplyMutation.isPending}
onCheckedChange={(val) => openPhoneReplyMutation.mutate(val)}
className="flex-shrink-0"
/>
</div>
</CardContent>
</Card>
{/* ── Section 0: SMS Templates ─────────────────────────────── */}
<Card>
<CardContent className="py-6 space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">SMS Templates</h3>
</div>
<Button
type="button"
size="sm"
variant="outline"
className="gap-1.5 text-xs"
onClick={addSmsTemplate}
>
<Plus className="h-3.5 w-3.5" />
Add Template
</Button>
</div>
<p className="text-sm text-muted-foreground">
Available variables:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{firstName}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeAddress}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono" title="Office call-in phone number">{"{officePhone}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono" title="Twilio SMS number patients text back to">{"{twilioPhone}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{appointmentDate}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{appointmentTime}"}</code>
</p>
{officeName && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
<Info className="h-3.5 w-3.5 flex-shrink-0" />
<span>
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
<span className="font-medium text-foreground">"{officeName}"</span>
</span>
</div>
)}
{smsLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : smsTemplates.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No templates yet. Click "Add Template" to create one.
</p>
) : (
<div className="space-y-4">
{smsTemplates.map((tpl, idx) => (
<div key={tpl.id} className="border rounded-lg p-4 space-y-3">
{/* Name row */}
<div className="flex items-center gap-2">
{idx === 0 && (
<span className="text-xs bg-teal-100 text-teal-700 font-medium px-2 py-0.5 rounded-full whitespace-nowrap">
Batch reminder
</span>
)}
<input
type="text"
value={tpl.name}
onChange={(e) => updateSmsTemplate(idx, "name", e.target.value)}
className="flex-1 p-2 border rounded text-sm font-medium min-w-0"
placeholder="Template name"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 flex-shrink-0"
onClick={() => deleteSmsTemplate(idx)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Body */}
<Textarea
value={tpl.body}
onChange={(e) => updateSmsTemplate(idx, "body", e.target.value)}
rows={3}
className="text-sm resize-none"
placeholder="Template content…"
/>
{/* Live preview */}
{officeName && tpl.body.includes("{officeName}") && (
<p className="text-xs text-muted-foreground italic pl-1">
Preview: {previewTemplate(tpl.body, officeName)}
</p>
)}
{/* Save button */}
<Button
type="button"
size="sm"
disabled={saveSmsListMutation.isPending}
className="bg-teal-600 hover:bg-teal-700 text-white"
onClick={() => saveSmsListMutation.mutate(smsTemplates)}
>
{saveSmsListMutation.isPending ? "Saving…" : "Save"}
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* ── Section 1: Chat Templates ────────────────────────────── */}
<Card>
<CardContent className="py-6 space-y-5">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Chat Templates</h3>
</div>
<p className="text-sm text-muted-foreground">
Customize how the AI assistant introduces itself to patients. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
as a placeholder — it will be replaced with your dental office name automatically.
</p>
{officeName && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
<Info className="h-3.5 w-3.5 flex-shrink-0" />
<span>
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
<span className="font-medium text-foreground">"{officeName}"</span>
</span>
</div>
)}
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading templates...</p>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{templateFields.map((f) => (
<div key={f.key} className="space-y-1.5">
<div className="flex items-center gap-2">
{f.icon}
<span className="text-sm font-medium">{f.label}</span>
</div>
<p className="text-xs text-muted-foreground">{f.description}</p>
<Textarea
value={f.value}
onChange={(e) => f.onChange(e.target.value)}
placeholder={f.placeholder}
rows={3}
className="text-sm resize-none"
/>
{officeName && f.value.includes("{officeName}") && (
<p className="text-xs text-muted-foreground italic pl-1">
Preview: {previewTemplate(f.value, officeName)}
</p>
)}
</div>
))}
<div className="flex items-center gap-3 pt-1">
<Button
type="submit"
disabled={saveMutation.isPending}
className="bg-teal-600 hover:bg-teal-700 text-white"
>
{saveMutation.isPending ? "Saving..." : "Save Templates"}
</Button>
<Button
type="button"
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => {
setReminderGreeting(DEFAULTS.reminderGreeting);
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
setGeneralFallback(DEFAULTS.generalFallback);
}}
>
Reset to defaults
</Button>
</div>
</form>
)}
</CardContent>
</Card>
{/* ── Section 1b: Call Templates ───────────────────────────── */}
<Card>
<CardContent className="py-6 space-y-5">
<div className="flex items-center gap-2">
<PhoneCall className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Call Templates</h3>
</div>
<p className="text-sm text-muted-foreground">
Customize what Lisa says on live AI phone calls — kept separate from the SMS wording
above since spoken conversation reads differently than a text message. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{firstName}"}</code>{" "}
as placeholders.
</p>
{officeName && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
<Info className="h-3.5 w-3.5 flex-shrink-0" />
<span>
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
<span className="font-medium text-foreground">"{officeName}"</span>
</span>
</div>
)}
{callTemplatesLoading ? (
<p className="text-sm text-muted-foreground">Loading templates...</p>
) : (
<form onSubmit={handleCallSubmit} className="space-y-5">
{callTemplateFields.map((f) => (
<div key={f.key} className="space-y-1.5">
<div className="flex items-center gap-2">
{f.icon}
<span className="text-sm font-medium">{f.label}</span>
</div>
<p className="text-xs text-muted-foreground">{f.description}</p>
<Textarea
value={f.value}
onChange={(e) => f.onChange(e.target.value)}
placeholder={f.placeholder}
rows={3}
className="text-sm resize-none"
/>
{officeName && f.value.includes("{officeName}") && (
<p className="text-xs text-muted-foreground italic pl-1">
Preview: {previewTemplate(f.value, officeName)}
</p>
)}
</div>
))}
<div className="flex items-center gap-3 pt-1">
<Button
type="submit"
disabled={saveCallMutation.isPending}
className="bg-teal-600 hover:bg-teal-700 text-white"
>
{saveCallMutation.isPending ? "Saving..." : "Save Templates"}
</Button>
<Button
type="button"
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => {
setCallGreeting(CALL_DEFAULTS.greeting);
setCallReminderGreeting(CALL_DEFAULTS.reminderGreeting);
setCallNewPatientGreeting(CALL_DEFAULTS.newPatientGreeting);
setCallGeneralFallback(CALL_DEFAULTS.generalFallback);
setCallRecallGreeting(CALL_DEFAULTS.recallGreeting);
}}
>
Reset to defaults
</Button>
</div>
</form>
)}
</CardContent>
</Card>
{/* ── Section 2: LangGraph Flow ────────────────────────────── */}
<Card>
<CardContent className="py-6 space-y-5">
<div className="flex items-center gap-2">
<GitFork className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">LangGraph Settings</h3>
</div>
<p className="text-sm text-muted-foreground">
Visual diagrams of each AI conversation graph — select a flow below to inspect its node sequence.
</p>
{/* Shared legend */}
<div className="flex flex-wrap gap-3 text-xs">
{[
{ bg: "bg-blue-100", border: "border-blue-400", label: "Staff action" },
{ bg: "bg-gray-100", border: "border-gray-400", label: "Patient action" },
{ bg: "bg-emerald-50", border: "border-emerald-400",label: "AI node" },
{ bg: "bg-indigo-50", border: "border-indigo-400", label: "New patient branch" },
{ bg: "bg-violet-50", border: "border-violet-400", label: "Existing patient" },
{ bg: "bg-green-50", border: "border-green-400", label: "Confirmed / Schedule"},
{ bg: "bg-orange-50", border: "border-orange-400", label: "Reschedule / Transfer"},
].map((l) => (
<span key={l.label} className="flex items-center gap-1.5">
<span className={`inline-block w-3 h-3 rounded-sm ${l.bg} border ${l.border}`} />
{l.label}
</span>
))}
</div>
<Tabs defaultValue="reminder">
<TabsList className="w-full">
<TabsTrigger value="reminder" className="flex-1 text-xs">
Reminder &amp; Reschedule Flow
</TabsTrigger>
<TabsTrigger value="new_patient" className="flex-1 text-xs">
New Patient Flow
</TabsTrigger>
</TabsList>
<TabsContent value="reminder">
<div className="overflow-x-auto rounded-lg border bg-gray-50/50 p-4 mt-3">
<LangGraphFlow />
</div>
</TabsContent>
<TabsContent value="new_patient">
<div className="overflow-x-auto rounded-lg border bg-gray-50/50 p-4 mt-3">
<NewPatientFlow />
</div>
<p className="text-xs text-muted-foreground mt-2 pl-1">
Any reply not matching a known intent returns: "Our office staff will assist you shortly."
</p>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* ── Section 3: Users AI Chat ─────────────────────────────── */}
<InternalChatSettingsCard />
</div>
);
}