feat: AI chat system with LangGraph, multi-step patient flows, and appointment rescheduling
- Add floating chat window Hand-off to AI toggle (per-patient) and after-hours AI toggle (global) - Add LangGraph-powered appointment reminder flow: AI introduces itself, classifies YES/NO, handles confirmation with appointment date/time - Add multi-step rescheduling flow: ASAP vs next week, tomorrow offer, Mon/Tue/Wed picker, morning/afternoon time slot — automatically updates appointment in DB - Add new patient / after-hours flow: new vs existing patient, dental insurance check, MassHealth Selenium eligibility check (auto-uses saved member ID + DOB for existing patients), self-pay fallback - Add AI Chat Settings page (Settings → Advanced) with editable greeting templates and LangGraph flow diagrams for both reminder and new-patient flows - Add Schedule a New Patient template option in chat window, starts new-patient conversation flow - Add GET/PUT endpoints for AI handoff, after-hours handoff, and AI chat templates - Add multilingual support (7 languages) across all AI reply nodes with LLM generation and hardcoded fallbacks - Add pending reschedule in-memory store and conversation stage tracking across all flows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
751
apps/Frontend/src/components/settings/ai-chat-settings-card.tsx
Normal file
751
apps/Frontend/src/components/settings/ai-chat-settings-card.tsx
Normal file
@@ -0,0 +1,751 @@
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AiChatTemplates = {
|
||||
reminderGreeting: string;
|
||||
newPatientGreeting: string;
|
||||
generalFallback: string;
|
||||
};
|
||||
|
||||
type OfficeContact = {
|
||||
officeName?: string | null;
|
||||
};
|
||||
|
||||
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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 your 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: "How can I help you today?",
|
||||
};
|
||||
|
||||
function previewTemplate(text: string, officeName: string) {
|
||||
return text.replace(/\{officeName\}/g, officeName || "your dental office");
|
||||
}
|
||||
|
||||
// ─── LangGraph flow diagram (SVG) ─────────────────────────────────────────────
|
||||
|
||||
function LangGraphFlow() {
|
||||
const W = 620;
|
||||
const cx = W / 2; // 310
|
||||
const nodeW = 200;
|
||||
const nx = cx - nodeW / 2; // 210
|
||||
|
||||
// sequential node y positions & heights
|
||||
const n1y = 16, n1h = 58;
|
||||
const n2y = 116, n2h = 58;
|
||||
const n3y = 216, n3h = 78;
|
||||
|
||||
// fork geometry
|
||||
const forkHY = n3y + n3h + 22; // 316
|
||||
const lcx = 150, rcx = 470; // branch centers (balanced around 310)
|
||||
|
||||
// main branch nodes
|
||||
const branchY = forkHY + 58; // 374
|
||||
const branchW = 220;
|
||||
const branchH = 88;
|
||||
const branchBottom = branchY + branchH; // 462
|
||||
|
||||
// ── Rescheduling sub-tree (below NO/left branch at lcx=150) ──────────────
|
||||
const rsForkHY = branchBottom + 28; // 490 patient says YES/NO to reschedule
|
||||
const rsYesCx = 90;
|
||||
const rsNoCx = 215;
|
||||
const rsNodeY = rsForkHY + 32; // 522
|
||||
const rsNodeH = 54;
|
||||
|
||||
// ASAP-or-next-week fork (below rsYesCx=90)
|
||||
const prefForkHY = rsNodeY + rsNodeH + 22; // 598
|
||||
const asakCx = 48;
|
||||
const nwkCx = 138;
|
||||
const prefNodeY = prefForkHY + 32; // 630
|
||||
const prefNodeH = 62;
|
||||
|
||||
const totalH = prefNodeY + prefNodeH + 54 + 20 + 54 + 22; // time node + DB node + padding
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${totalH}`}
|
||||
className="w-full max-w-xl mx-auto"
|
||||
role="img"
|
||||
aria-label="LangGraph conversation flow diagram"
|
||||
>
|
||||
<defs>
|
||||
{/* Arrowhead marker */}
|
||||
<marker
|
||||
id="ah"
|
||||
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>
|
||||
|
||||
{/* ── Node 1: Office sends reminder ─────────────────────────── */}
|
||||
<rect x={nx} y={n1y} width={nodeW} height={n1h} rx={8}
|
||||
fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={cx} y={n1y + 24} textAnchor="middle" fontSize={13}
|
||||
fontWeight="600" fill="#1D4ED8">Office sends reminder</text>
|
||||
<text x={cx} y={n1y + 42} textAnchor="middle" fontSize={10}
|
||||
fill="#93C5FD">Staff triggers the SMS</text>
|
||||
|
||||
{/* Arrow 1 → 2 */}
|
||||
<line x1={cx} y1={n1y + n1h} x2={cx} y2={n2y - 2}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* ── Node 2: Patient replies ───────────────────────────────── */}
|
||||
<rect x={nx} y={n2y} width={nodeW} height={n2h} rx={8}
|
||||
fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
||||
<text x={cx} y={n2y + 24} textAnchor="middle" fontSize={13}
|
||||
fontWeight="600" fill="#374151">Patient replies</text>
|
||||
<text x={cx} y={n2y + 42} textAnchor="middle" fontSize={10}
|
||||
fill="#9CA3AF">Any message triggers the AI</text>
|
||||
|
||||
{/* Arrow 2 → 3 */}
|
||||
<line x1={cx} y1={n2y + n2h} x2={cx} y2={n3y - 2}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* ── Node 3: AI introduces itself ──────────────────────────── */}
|
||||
<rect x={nx} y={n3y} width={nodeW} height={n3h} rx={8}
|
||||
fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={cx} y={n3y + 24} textAnchor="middle" fontSize={13}
|
||||
fontWeight="600" fill="#065F46">AI introduces itself</text>
|
||||
<text x={cx} y={n3y + 43} textAnchor="middle" fontSize={9}
|
||||
fill="#6B7280" fontStyle="italic">"Hi! My name is Lisa, the dedicated</text>
|
||||
<text x={cx} y={n3y + 57} textAnchor="middle" fontSize={9}
|
||||
fill="#6B7280" fontStyle="italic">AI assistant at {"{officeName}"}..."</text>
|
||||
|
||||
{/* ── Fork connectors ───────────────────────────────────────── */}
|
||||
{/* Vertical down from N3 */}
|
||||
<line x1={cx} y1={n3y + n3h} x2={cx} y2={forkHY}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Horizontal fork line */}
|
||||
<line x1={lcx} y1={forkHY} x2={rcx} y2={forkHY}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Left drop → NO branch */}
|
||||
<line x1={lcx} y1={forkHY} x2={lcx} y2={branchY - 2}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
{/* Right drop → YES branch */}
|
||||
<line x1={rcx} y1={forkHY} x2={rcx} y2={branchY - 2}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* ── YES / NO badges on the fork ───────────────────────────── */}
|
||||
{/* NO badge — left junction */}
|
||||
<rect x={lcx - 28} y={forkHY - 12} width={56} height={24} rx={12}
|
||||
fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={lcx} y={forkHY + 5} textAnchor="middle" fontSize={11}
|
||||
fontWeight="700" fill="#EA580C">NO</text>
|
||||
|
||||
{/* YES badge — right junction */}
|
||||
<rect x={rcx - 24} y={forkHY - 12} width={48} height={24} rx={12}
|
||||
fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rcx} y={forkHY + 5} textAnchor="middle" fontSize={11}
|
||||
fontWeight="700" fill="#15803D">YES</text>
|
||||
|
||||
{/* ── Left branch: NO → "Would you like to reschedule?" ───────── */}
|
||||
<rect x={lcx - branchW/2} y={branchY} width={branchW} height={branchH}
|
||||
rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={lcx} y={branchY+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">It is understandable!</text>
|
||||
<text x={lcx} y={branchY+40} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Would you like to</text>
|
||||
<text x={lcx} y={branchY+58} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">reschedule?</text>
|
||||
|
||||
{/* ── Right branch: YES → Confirm ───────────────────────────── */}
|
||||
<rect x={rcx - branchW/2} y={branchY} width={branchW} height={branchH}
|
||||
rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rcx} y={branchY+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#15803D">Thank you for</text>
|
||||
<text x={rcx} y={branchY+40} textAnchor="middle" fontSize={12} fontWeight="600" fill="#15803D">confirming!</text>
|
||||
<text x={rcx} y={branchY+60} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"See you on [date & time]"</text>
|
||||
|
||||
{/* ══════════ RESCHEDULING SUB-TREE ══════════════════════════════ */}
|
||||
|
||||
{/* Vertical from NO node bottom */}
|
||||
<line x1={lcx} y1={branchBottom} x2={lcx} y2={rsForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Horizontal YES/NO fork */}
|
||||
<line x1={rsYesCx} y1={rsForkHY} x2={rsNoCx} y2={rsForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={rsYesCx} y1={rsForkHY} x2={rsYesCx} y2={rsNodeY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
<line x1={rsNoCx} y1={rsForkHY} x2={rsNoCx} y2={rsNodeY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* YES / NO badges */}
|
||||
<rect x={rsYesCx-20} y={rsForkHY-11} width={40} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rsYesCx} y={rsForkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">YES</text>
|
||||
<rect x={rsNoCx-18} y={rsForkHY-11} width={36} height={22} rx={11} fill="#F9FAFB" stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<text x={rsNoCx} y={rsForkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#6B7280">NO</text>
|
||||
|
||||
{/* YES → "ASAP or next week?" node */}
|
||||
<rect x={rsYesCx-52} y={rsNodeY} width={104} height={rsNodeH} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={rsYesCx} y={rsNodeY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">ASAP or</text>
|
||||
<text x={rsYesCx} y={rsNodeY+32} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">next week?</text>
|
||||
<text x={rsYesCx} y={rsNodeY+46} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">(if Mon–Thu appt)</text>
|
||||
|
||||
{/* NO → polite close */}
|
||||
<rect x={rsNoCx-46} y={rsNodeY} width={92} height={rsNodeH} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
||||
<text x={rsNoCx} y={rsNodeY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#374151">No problem!</text>
|
||||
<text x={rsNoCx} y={rsNodeY+36} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Conversation ends</text>
|
||||
|
||||
{/* Vertical from "ASAP or next week?" down to preference fork */}
|
||||
<line x1={rsYesCx} y1={rsNodeY+rsNodeH} x2={rsYesCx} y2={prefForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* ASAP / Next week fork */}
|
||||
<line x1={asakCx} y1={prefForkHY} x2={nwkCx} y2={prefForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={asakCx} y1={prefForkHY} x2={asakCx} y2={prefNodeY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
<line x1={nwkCx} y1={prefForkHY} x2={nwkCx} y2={prefNodeY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* ASAP badge */}
|
||||
<rect x={asakCx-22} y={prefForkHY-11} width={44} height={22} rx={11} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={asakCx} y={prefForkHY+4} textAnchor="middle" fontSize={9} fontWeight="700" fill="#1D4ED8">ASAP</text>
|
||||
{/* Next week badge */}
|
||||
<rect x={nwkCx-30} y={prefForkHY-11} width={60} height={22} rx={11} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
|
||||
<text x={nwkCx} y={prefForkHY+4} textAnchor="middle" fontSize={9} fontWeight="700" fill="#6D28D9">Next week</text>
|
||||
|
||||
{/* ASAP → "Can you come tomorrow?" */}
|
||||
<rect x={asakCx-44} y={prefNodeY} width={88} height={prefNodeH} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={asakCx} y={prefNodeY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Can you come</text>
|
||||
<text x={asakCx} y={prefNodeY+30} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">tomorrow?</text>
|
||||
<text x={asakCx} y={prefNodeY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">YES → ask time</text>
|
||||
|
||||
{/* Next week → "Mon, Tue, or Wed?" */}
|
||||
<rect x={nwkCx-46} y={prefNodeY} width={92} height={prefNodeH} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
|
||||
<text x={nwkCx} y={prefNodeY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Mon, Tue,</text>
|
||||
<text x={nwkCx} y={prefNodeY+30} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">or Wed?</text>
|
||||
<text x={nwkCx} y={prefNodeY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">→ ask time</text>
|
||||
|
||||
{/* ── Time-slot node (shared by both paths) ─────────────────── */}
|
||||
{/* Left vertical from ASAP node */}
|
||||
<line x1={asakCx} y1={prefNodeY+prefNodeH} x2={asakCx} y2={prefNodeY+prefNodeH+18}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Right vertical from NextWeek node */}
|
||||
<line x1={nwkCx} y1={prefNodeY+prefNodeH} x2={nwkCx} y2={prefNodeY+prefNodeH+18}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Horizontal converge line */}
|
||||
<line x1={asakCx} y1={prefNodeY+prefNodeH+18} x2={nwkCx} y2={prefNodeY+prefNodeH+18}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Drop to time node */}
|
||||
<line x1={rsYesCx} y1={prefNodeY+prefNodeH+18} x2={rsYesCx} y2={prefNodeY+prefNodeH+36}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* Time-slot node */}
|
||||
<rect x={rsYesCx-52} y={prefNodeY+prefNodeH+40} width={104} height={54} rx={8}
|
||||
fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+58} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">Morning or</text>
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+72} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">afternoon?</text>
|
||||
|
||||
{/* DB update node (dashed) */}
|
||||
<line x1={rsYesCx} y1={prefNodeY+prefNodeH+94} x2={rsYesCx} y2={prefNodeY+prefNodeH+112}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
<rect x={rsYesCx-52} y={prefNodeY+prefNodeH+116} width={104} height={54} rx={8}
|
||||
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
|
||||
<rect x={rsYesCx-38} y={prefNodeY+prefNodeH+108} width={76} height={20} rx={10}
|
||||
fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+122} textAnchor="middle" fontSize={9} fontWeight="700" fill="#0369A1">DB Update</text>
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+138} textAnchor="middle" fontSize={10} fontWeight="600" fill="#0369A1">Appt. moved!</text>
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+154} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [day] at [time]"</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── New Patient / After-Hours flow diagram ───────────────────────────────────
|
||||
|
||||
function NewPatientFlow() {
|
||||
// ── Canvas ───────────────────────────────────────────────────────────────
|
||||
const W = 760;
|
||||
const cx = 380; // top-node horizontal center
|
||||
const nW = 220;
|
||||
const nx = cx - nW / 2; // 270
|
||||
|
||||
// ── Sequential top nodes ─────────────────────────────────────────────────
|
||||
const n1y = 15, n1h = 58;
|
||||
const n2y = 115, n2h = 58;
|
||||
const n3y = 215, n3h = 58;
|
||||
const n4y = 315, n4h = 60;
|
||||
|
||||
// ── Main fork ────────────────────────────────────────────────────────────
|
||||
const forkHY = n4y + n4h + 20; // 395
|
||||
const lcx = 165; // New Patient branch center
|
||||
const rcx = 590; // Existing Patient branch center
|
||||
const brW = 200;
|
||||
|
||||
// ── Main branch nodes ─────────────────────────────────────────────────────
|
||||
const brY = forkHY + 50; // 445
|
||||
const brH = 62;
|
||||
|
||||
// ── Sub-forks ─────────────────────────────────────────────────────────────
|
||||
const sfHY = brY + brH + 18; // 525
|
||||
const llcx = 100; const lrcx = 230; // New Patient sub-branches (centered on 165)
|
||||
const rlcx = 510; const rrcx = 660; // Existing Patient sub-branches
|
||||
|
||||
// ── Leaf nodes ────────────────────────────────────────────────────────────
|
||||
const leafY = sfHY + 46; // 571
|
||||
const leafW = 118;
|
||||
const leafH = 80;
|
||||
|
||||
// ── New patient: Selenium chain (below LL at cx=100) ─────────────────────
|
||||
const npSelY = leafY + leafH + 34; // 685 — Selenium node top
|
||||
const npSelW = 140;
|
||||
const npSelH = 56;
|
||||
const npResHY = npSelY + npSelH + 16; // 757 — result fork y
|
||||
const npActCx = 52; // ACTIVE result center (100-48)
|
||||
const npInaCx = 148; // INACTIVE result center (100+48)
|
||||
const npResY = npResHY + 34; // 791
|
||||
const npResW = 80;
|
||||
const npResH = 72;
|
||||
|
||||
// ── Existing patient: YES → MassHealth auto-check (below RL at cx=510) ──
|
||||
const exForkHY = leafY + leafH + 26; // 677
|
||||
const exMhCx = 445; // MassHealth sub-branch center
|
||||
const exOtherCx = 558; // Other insurance center
|
||||
const exCheckY = exForkHY + 34; // 711
|
||||
const exCheckW = 116;
|
||||
const exCheckH = 52;
|
||||
const exSelY = exCheckY + exCheckH + 18; // 781
|
||||
const exSelW = 128;
|
||||
const exSelH = 52;
|
||||
const exResHY = exSelY + exSelH + 16; // 849
|
||||
const exActCx = 401; // (445-44)
|
||||
const exInaCx = 489; // (445+44)
|
||||
const exLeafY = exResHY + 34; // 883
|
||||
const exLeafW = 80;
|
||||
const exLeafH = 62;
|
||||
|
||||
const totalH = Math.max(npResY + npResH, exLeafY + exLeafH) + 22;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${totalH}`}
|
||||
className="w-full max-w-2xl mx-auto"
|
||||
role="img"
|
||||
aria-label="New patient conversation flow diagram"
|
||||
>
|
||||
<defs>
|
||||
<marker id="nph" 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>
|
||||
|
||||
{/* ═══════════ TOP SEQUENCE ═══════════════════════════════════ */}
|
||||
|
||||
<rect x={nx} y={n1y} width={nW} height={n1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={cx} y={n1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Patient texts after hours</text>
|
||||
<text x={cx} y={n1y+38} textAnchor="middle" fontSize={10} fill="#93C5FD">or staff selects</text>
|
||||
<text x={cx} y={n1y+50} textAnchor="middle" fontSize={10} fill="#93C5FD">"Schedule a New Patient"</text>
|
||||
|
||||
<line x1={cx} y1={n1y+n1h} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={nx} y={n2y} width={nW} height={n2h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={cx} y={n2y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#065F46">AI sends New Patient Greeting</text>
|
||||
<text x={cx} y={n2y+40} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Hi! My name is Lisa..."</text>
|
||||
|
||||
<line x1={cx} y1={n2y+n2h} x2={cx} y2={n3y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
||||
<text x={cx} y={n3y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#374151">Patient replies</text>
|
||||
<text x={cx} y={n3y+40} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"I want an appointment / cleaning"</text>
|
||||
|
||||
<line x1={cx} y1={n3y+n3h} x2={cx} y2={n4y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={nx} y={n4y} width={nW} height={n4h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={cx} y={n4y+24} textAnchor="middle" fontSize={12} fontWeight="600" fill="#065F46">AI: New or existing patient?</text>
|
||||
<text x={cx} y={n4y+42} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Are you a new or existing patient?"</text>
|
||||
|
||||
{/* ═══════════ MAIN FORK ══════════════════════════════════════ */}
|
||||
|
||||
<line x1={cx} y1={n4y+n4h} 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={brY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={rcx} y1={forkHY} x2={rcx} y2={brY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={lcx-42} y={forkHY-13} width={84} 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-50} y={forkHY-13} width={100} 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>
|
||||
|
||||
{/* ═══════════ LEFT BRANCH — NEW PATIENT ══════════════════════ */}
|
||||
|
||||
<rect x={lcx-brW/2} y={brY} width={brW} height={brH} rx={8} fill="#EEF2FF" stroke="#6366F1" strokeWidth={1.5} />
|
||||
<text x={lcx} y={brY+22} textAnchor="middle" fontSize={11} fontWeight="600" fill="#3730A3">Do you have any</text>
|
||||
<text x={lcx} y={brY+38} textAnchor="middle" fontSize={11} fontWeight="600" fill="#3730A3">dental insurance?</text>
|
||||
|
||||
<line x1={lcx} y1={brY+brH} x2={lcx} y2={sfHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={llcx} y1={sfHY} x2={lrcx} y2={sfHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={llcx} y1={sfHY} x2={llcx} y2={leafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={lrcx} y1={sfHY} x2={lrcx} y2={leafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={llcx-36} y={sfHY-11} width={72} height={22} rx={11} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={llcx} y={sfHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#065F46">MassHealth</text>
|
||||
<rect x={lrcx-32} y={sfHY-11} width={64} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={lrcx} y={sfHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">No ins.</text>
|
||||
|
||||
{/* NP: MassHealth leaf — ask for ID+DOB */}
|
||||
<rect x={llcx-leafW/2} y={leafY} width={leafW} height={leafH} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={llcx} y={leafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">Check MassHealth</text>
|
||||
<text x={llcx} y={leafY+34} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"Text me your</text>
|
||||
<text x={llcx} y={leafY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Member ID & DOB"</text>
|
||||
|
||||
{/* NP: No insurance leaf */}
|
||||
<rect x={lrcx-leafW/2} y={leafY} width={leafW} height={leafH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={lrcx} y={leafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Schedule</text>
|
||||
<text x={lrcx} y={leafY+34} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"When would</text>
|
||||
<text x={lrcx} y={leafY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">you like to come?"</text>
|
||||
|
||||
{/* NP: Selenium check (patient texts ID+DOB) */}
|
||||
<line x1={llcx} y1={leafY+leafH} x2={llcx} y2={npSelY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<rect x={llcx-npSelW/2} y={npSelY} width={npSelW} height={npSelH} rx={8}
|
||||
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
|
||||
<rect x={llcx-40} y={npSelY-11} width={80} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||
<text x={llcx} y={npSelY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Selenium</text>
|
||||
<text x={llcx} y={npSelY+20} textAnchor="middle" fontSize={10} fontWeight="600" fill="#0369A1">MassHealth Portal</text>
|
||||
<text x={llcx} y={npSelY+36} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Auto-checks eligibility</text>
|
||||
<text x={llcx} y={npSelY+48} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">→ result SMS to patient</text>
|
||||
|
||||
{/* NP: Post-Selenium ACTIVE/INACTIVE fork */}
|
||||
<line x1={llcx} y1={npSelY+npSelH} x2={llcx} 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(#nph)" />
|
||||
<line x1={npInaCx} y1={npResHY} x2={npInaCx} y2={npResY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<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>
|
||||
|
||||
<rect x={npActCx-npResW/2} y={npResY} width={npResW} height={npResH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={npActCx} y={npResY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Check-up or</text>
|
||||
<text x={npActCx} y={npResY+30} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">tooth problem?</text>
|
||||
<text x={npActCx} y={npResY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">→ book appt</text>
|
||||
|
||||
<rect x={npInaCx-npResW/2} y={npResY} width={npResW} height={npResH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={npInaCx} y={npResY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Inactive.</text>
|
||||
<text x={npInaCx} y={npResY+30} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Self-pay?</text>
|
||||
<text x={npInaCx} y={npResY+50} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">YES/NO</text>
|
||||
|
||||
{/* ═══════════ RIGHT BRANCH — EXISTING PATIENT ════════════════ */}
|
||||
|
||||
<rect x={rcx-brW/2} y={brY} width={brW} height={brH} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
|
||||
<text x={rcx} y={brY+22} textAnchor="middle" fontSize={11} fontWeight="600" fill="#5B21B6">Do you still have</text>
|
||||
<text x={rcx} y={brY+38} textAnchor="middle" fontSize={11} fontWeight="600" fill="#5B21B6">the same insurance?</text>
|
||||
|
||||
<line x1={rcx} y1={brY+brH} x2={rcx} y2={sfHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={rlcx} y1={sfHY} x2={rrcx} y2={sfHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={rlcx} y1={sfHY} x2={rlcx} y2={leafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={rrcx} y1={sfHY} x2={rrcx} y2={leafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={rlcx-20} y={sfHY-11} width={40} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rlcx} y={sfHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">YES</text>
|
||||
<rect x={rrcx-20} y={sfHY-11} width={40} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={rrcx} y={sfHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">NO</text>
|
||||
|
||||
{/* EP: YES — "Same insurance confirmed" */}
|
||||
<rect x={rlcx-leafW/2} y={leafY} width={leafW} height={leafH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rlcx} y={leafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Same insurance</text>
|
||||
<text x={rlcx} y={leafY+34} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">MassHealth → auto-check</text>
|
||||
<text x={rlcx} y={leafY+48} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Other → schedule</text>
|
||||
|
||||
{/* EP: NO — Transfer */}
|
||||
<rect x={rrcx-leafW/2} y={leafY} width={leafW} height={leafH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={rrcx} y={leafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Transfer</text>
|
||||
<text x={rrcx} y={leafY+34} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"Our staff will</text>
|
||||
<text x={rrcx} y={leafY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">assist you"</text>
|
||||
|
||||
{/* EP: YES → MassHealth sub-fork */}
|
||||
<line x1={rlcx} y1={leafY+leafH} x2={rlcx} y2={exForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={exMhCx} y1={exForkHY} x2={exOtherCx} y2={exForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={exMhCx} y1={exForkHY} x2={exMhCx} y2={exCheckY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={exOtherCx} y1={exForkHY} x2={exOtherCx} y2={exCheckY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={exMhCx-36} y={exForkHY-11} width={72} height={22} rx={11} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={exMhCx} y={exForkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#065F46">MassHealth</text>
|
||||
<rect x={exOtherCx-32} y={exForkHY-11} width={64} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||
<text x={exOtherCx} y={exForkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Other ins.</text>
|
||||
|
||||
{/* EP: MassHealth — "Checking 30-60s" node */}
|
||||
<rect x={exMhCx-exCheckW/2} y={exCheckY} width={exCheckW} height={exCheckH} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={exMhCx} y={exCheckY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">Wait 30-60 secs…</text>
|
||||
<text x={exMhCx} y={exCheckY+34} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Using saved ID & DOB</text>
|
||||
|
||||
{/* EP: Other insurance — schedule directly */}
|
||||
<rect x={exOtherCx-exCheckW/2} y={exCheckY} width={exCheckW} height={exCheckH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={exOtherCx} y={exCheckY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Schedule</text>
|
||||
<text x={exOtherCx} y={exCheckY+34} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"When to come in?"</text>
|
||||
|
||||
{/* EP: MassHealth → Selenium */}
|
||||
<line x1={exMhCx} y1={exCheckY+exCheckH} x2={exMhCx} y2={exSelY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<rect x={exMhCx-exSelW/2} y={exSelY} width={exSelW} height={exSelH} rx={8}
|
||||
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
|
||||
<rect x={exMhCx-38} y={exSelY-11} width={76} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||
<text x={exMhCx} y={exSelY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Selenium</text>
|
||||
<text x={exMhCx} y={exSelY+20} textAnchor="middle" fontSize={10} fontWeight="600" fill="#0369A1">MassHealth Portal</text>
|
||||
<text x={exMhCx} y={exSelY+36} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Auto-checks eligibility</text>
|
||||
|
||||
{/* EP: ACTIVE / INACTIVE fork */}
|
||||
<line x1={exMhCx} y1={exSelY+exSelH} x2={exMhCx} y2={exResHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={exActCx} y1={exResHY} x2={exInaCx} y2={exResHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={exActCx} y1={exResHY} x2={exActCx} y2={exLeafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={exInaCx} y1={exResHY} x2={exInaCx} y2={exLeafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={exActCx-28} y={exResHY-11} width={56} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={exActCx} y={exResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">ACTIVE</text>
|
||||
<rect x={exInaCx-34} y={exResHY-11} width={68} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={exInaCx} y={exResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">INACTIVE</text>
|
||||
|
||||
<rect x={exActCx-exLeafW/2} y={exLeafY} width={exLeafW} height={exLeafH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={exActCx} y={exLeafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">When to</text>
|
||||
<text x={exActCx} y={exLeafY+32} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">come in?</text>
|
||||
<text x={exActCx} y={exLeafY+50} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">→ book appt</text>
|
||||
|
||||
<rect x={exInaCx-exLeafW/2} y={exLeafY} width={exLeafW} height={exLeafH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={exInaCx} y={exLeafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Inactive.</text>
|
||||
<text x={exInaCx} y={exLeafY+32} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Self-pay?</text>
|
||||
<text x={exInaCx} y={exLeafY+50} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">YES/NO</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 { 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,
|
||||
});
|
||||
|
||||
// Seed local state from server on first load only
|
||||
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 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 handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate({
|
||||
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
||||
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
||||
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── 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 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">
|
||||
Appointment Reminder Flow
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="new_patient" className="flex-1 text-xs">
|
||||
New Patient / After-Hours 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>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
apps/Frontend/src/components/settings/ai-chat-templates-card.tsx
Normal file
209
apps/Frontend/src/components/settings/ai-chat-templates-card.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info } from "lucide-react";
|
||||
|
||||
type AiChatTemplates = {
|
||||
reminderGreeting: string;
|
||||
newPatientGreeting: string;
|
||||
generalFallback: string;
|
||||
};
|
||||
|
||||
type OfficeContact = {
|
||||
officeName?: string | null;
|
||||
};
|
||||
|
||||
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. How can I help you today?",
|
||||
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:
|
||||
"How can I help you today?",
|
||||
};
|
||||
|
||||
function preview(text: string, officeName: string) {
|
||||
return text.replace(/\{officeName\}/g, officeName || "your dental office");
|
||||
}
|
||||
|
||||
export function AiChatTemplatesCard() {
|
||||
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 { 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, // never silently refetch and overwrite user edits
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Seed state from server on first successful load only
|
||||
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 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 handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate({
|
||||
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
||||
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
||||
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
||||
});
|
||||
};
|
||||
|
||||
const officeName = officeContact?.officeName?.trim() || "";
|
||||
|
||||
const templates_list = [
|
||||
{
|
||||
key: "reminder" as const,
|
||||
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
|
||||
label: "Appointment Reminder Reply",
|
||||
description: "Sent when the AI introduces itself after the office sends an appointment reminder.",
|
||||
value: reminderGreeting,
|
||||
onChange: setReminderGreeting,
|
||||
placeholder: DEFAULTS.reminderGreeting,
|
||||
},
|
||||
{
|
||||
key: "newPatient" as const,
|
||||
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" as const,
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">AI Chat Templates</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize how your AI assistant introduces itself and responds 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 automatically with your dental office name.
|
||||
</p>
|
||||
|
||||
{/* Office name hint */}
|
||||
{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-6">
|
||||
{templates_list.map((t) => (
|
||||
<div key={t.key} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{t.icon}
|
||||
<span className="text-sm font-medium">{t.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t.description}</p>
|
||||
<Textarea
|
||||
value={t.value}
|
||||
onChange={(e) => t.onChange(e.target.value)}
|
||||
placeholder={t.placeholder}
|
||||
rows={3}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
{/* Live preview */}
|
||||
{officeName && t.value.includes("{officeName}") && (
|
||||
<p className="text-xs text-muted-foreground italic pl-1">
|
||||
Preview: {preview(t.value, officeName)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user