- 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>
1686 lines
90 KiB
TypeScript
1686 lines
90 KiB
TypeScript
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 & 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 & time?</text>
|
||
<text x={lcx} y={yes1y+46} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: What day & time?</text>
|
||
<text x={lcx} y={yes1y+64} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||
);
|
||
}
|