import { useState, useRef, useEffect } from "react"; import { Bot, X, ChevronRight, Stethoscope, Calendar, FileText, Send, Loader2, RotateCcw, Paperclip, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { useLocation } from "wouter"; import { cn } from "@/lib/utils"; import { apiRequest } from "@/lib/queryClient"; import { setChatbotPendingFiles } from "@/lib/chatbotFileStore"; let msgCounter = 0; function makeMsg(role, text, isLoading = false) { return { id: ++msgCounter, role, text, isLoading }; } function getAutoCheck(dobISO) { const [y, m, d] = dobISO.split("-").map(Number); const today = new Date(); let age = today.getFullYear() - (y ?? 0); const monthDiff = today.getMonth() + 1 - (m ?? 0); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < (d ?? 0))) age--; return age >= 21 ? "mh" : "cmsp"; } function parseEligibilityInput(raw) { const dateMatch = raw.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\b/); if (!dateMatch) return null; const m = dateMatch[1]; const d = dateMatch[2]; const y = dateMatch[3]; if (!m || !d || !y) return null; const month = parseInt(m, 10); const day = parseInt(d, 10); const year = parseInt(y, 10); if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900) return null; const withoutDate = raw.replace(dateMatch[0], ""); const memberId = (withoutDate.match(/[a-zA-Z0-9]/g) ?? []).join(""); if (!memberId) return null; return { memberId, display: `${m.padStart(2, "0")}/${d.padStart(2, "0")}/${y}`, iso: `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`, }; } const CHAT_STORAGE_KEY = "chatbot_messages"; const CHATBOT_JOB_TS_KEY = "chatbot_job_started_at"; function markJobStarted() { try { sessionStorage.setItem(CHATBOT_JOB_TS_KEY, String(Date.now())); } catch { } } function shouldAutoReset() { try { const ts = sessionStorage.getItem(CHATBOT_JOB_TS_KEY); if (!ts) return false; return Date.now() - Number(ts) > 60_000; } catch { return false; } } function loadSavedMessages() { try { const raw = sessionStorage.getItem(CHAT_STORAGE_KEY); if (raw) return JSON.parse(raw); } catch { } return [makeMsg("bot", "Hi! What can I help you with today?")]; } const WELCOME = [makeMsg("bot", "Hi! What can I help you with today?")]; let saveTimer = null; function saveChatHistoryToServer(msgs) { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { const saveable = msgs.filter((m) => !m.isLoading); fetch("/api/ai/chat-history", { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }, body: JSON.stringify({ messages: saveable }), }).catch(() => { }); }, 500); } function clearChatHistoryOnServer() { fetch("/api/ai/chat-history", { method: "DELETE", headers: { Authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }, }).catch(() => { }); } export function ChatbotButton() { const [open, setOpen] = useState(false); const [step, setStep] = useState("menu"); const [messages, setMessages] = useState(loadSavedMessages); const [pasteInput, setPasteInput] = useState(""); const [parseError, setParseError] = useState(""); const [eligibilityData, setEligibilityData] = useState(null); const [freeTextInput, setFreeTextInput] = useState(""); const [patientResult, setPatientResult] = useState(null); const [eligibilityIdData, setEligibilityIdData] = useState(null); const [batchEligibilityData, setBatchEligibilityData] = useState(null); const [checkAndClaimData, setCheckAndClaimData] = useState(null); const [clarificationData, setClarificationData] = useState(null); const [apptSelectionData, setApptSelectionData] = useState(null); const [cdtClarificationData, setCdtClarificationData] = useState(null); const [claimReadyData, setClaimReadyData] = useState(null); const [batchCheckAndClaimData, setBatchCheckAndClaimData] = useState(null); const [batchClaimData, setBatchClaimData] = useState(null); const [preauthReadyData, setPreauthReadyData] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); const [, setLocation] = useLocation(); const messagesEndRef = useRef(null); const pasteRef = useRef(null); const freeTextRef = useRef(null); const fileInputRef = useRef(null); // Load chat history from server on first mount (server is source of truth) const serverLoaded = useRef(false); useEffect(() => { if (serverLoaded.current) return; serverLoaded.current = true; const token = localStorage.getItem("token"); if (!token) return; fetch("/api/ai/chat-history", { headers: { Authorization: `Bearer ${token}` }, }) .then((r) => r.json()) .then((data) => { if (Array.isArray(data.messages) && data.messages.length > 0) { setMessages(data.messages); try { sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(data.messages)); } catch { } } }) .catch(() => { }); }, []); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, step]); // Persist messages to sessionStorage AND server useEffect(() => { try { const saveable = messages.filter((m) => !m.isLoading); sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable)); saveChatHistoryToServer(messages); } catch { } }, [messages]); useEffect(() => { if (step === "eligibility-input") { setTimeout(() => pasteRef.current?.focus(), 50); } if (step === "menu") { setTimeout(() => freeTextRef.current?.focus(), 50); } }, [step]); const addMsg = (role, text, isLoading = false) => setMessages((prev) => [...prev, makeMsg(role, text, isLoading)]); const replaceLastMsg = (text) => setMessages((prev) => { const next = [...prev]; const last = next[next.length - 1]; if (last) next[next.length - 1] = { ...last, text, isLoading: false }; return next; }); // Resets step/data only — keeps message history const resetStep = () => { setStep("menu"); setPasteInput(""); setParseError(""); setEligibilityData(null); setFreeTextInput(""); setPatientResult(null); setEligibilityIdData(null); setBatchEligibilityData(null); setCheckAndClaimData(null); setClarificationData(null); setApptSelectionData(null); setCdtClarificationData(null); setClaimReadyData(null); setBatchClaimData(null); setBatchCheckAndClaimData(null); setPreauthReadyData(null); }; // Full reset including message history and stored session const reset = () => { resetStep(); setPendingFiles([]); const fresh = [makeMsg("bot", "Hi! What can I help you with today?")]; try { sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh)); } catch { } clearChatHistoryOnServer(); setMessages(fresh); }; const handleClose = () => { setOpen(false); resetStep(); }; const handleOptionSelect = (option) => { if (option === "schedule") { addMsg("user", "Schedule an appointment"); addMsg("bot", "Opening the appointments page..."); setTimeout(() => { setLocation("/appointments"); setOpen(false); resetStep(); }, 600); } else if (option === "claims") { addMsg("user", "View claims"); addMsg("bot", "Opening the claims page..."); setTimeout(() => { setLocation("/claims"); setOpen(false); resetStep(); }, 600); } else if (option === "eligibility") { addMsg("user", "Check Eligibility"); addMsg("bot", "Please enter the patient's Member ID and Date of Birth:"); setStep("eligibility-input"); } }; const handleEligibilitySubmit = () => { const parsed = parseEligibilityInput(pasteInput); if (!parsed) { setParseError("Couldn't find both a Member ID and a date. Try: 123456789 01/15/1985"); return; } setParseError(""); const data = { memberId: parsed.memberId, dob: parsed.display, dobISO: parsed.iso }; setEligibilityData(data); addMsg("user", pasteInput.trim()); addMsg("bot", `Ready to check MassHealth eligibility for:\n• Member ID: ${parsed.memberId}\n• Date of Birth: ${parsed.display}\n\nShall I proceed?`); setStep("eligibility-confirm"); }; const handleConfirm = () => { if (!eligibilityData) return; addMsg("user", "Yes, check now"); addMsg("bot", "Opening the eligibility check page with this patient..."); sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId: eligibilityData.memberId, dob: eligibilityData.dobISO, autoCheck: getAutoCheck(eligibilityData.dobISO), })); window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600); }; const handleEligibilityFromPatient = () => { if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return; addMsg("user", "Check eligibility now"); addMsg("bot", "Opening the eligibility check page..."); prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth)); }; const handleEligibilityAndAppointmentFromPatient = async () => { if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return; addMsg("user", "Check eligibility & add to schedule (today)"); addMsg("bot", "Creating appointment for today...", true); try { const res = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patientResult.id, }); const data = await res.json(); if (!res.ok) { replaceLastMsg(data.message ?? "Could not create appointment."); return; } replaceLastMsg(`Appointment added at ${data.startTime} (${data.column ?? "Column A"}) — opening eligibility check page...`); } catch { replaceLastMsg("Could not create appointment — opening eligibility check page..."); } prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth)); }; const prefillAndNavigate = (memberId, dobISO, autoCheck) => { sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck })); window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); markJobStarted(); setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600); }; const handleEligibilityIdRun = () => { if (!eligibilityIdData) return; addMsg("user", "Check eligibility now"); addMsg("bot", "Opening the eligibility check page..."); prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); }; const handleBatchEligibilityRun = () => { if (!batchEligibilityData || batchEligibilityData.length === 0) return; addMsg("user", `Check all ${batchEligibilityData.length} patients`); addMsg("bot", `Checking ${batchEligibilityData.length} patients one by one...`); const [first, ...rest] = batchEligibilityData; if (rest.length > 0) { sessionStorage.setItem("chatbot_eligibility_queue", JSON.stringify(rest)); } prefillAndNavigate(first.memberId, first.dob, first.autoCheck); }; const handleBatchEligibilityAndAppointment = () => { if (!batchEligibilityData || batchEligibilityData.length === 0) return; addMsg("user", `Check all & appointment today (${batchEligibilityData.length} patients)`); addMsg("bot", `Checking ${batchEligibilityData.length} patients — appointments will be created after each check...`); sessionStorage.setItem("chatbot_batch_appt_after_eligibility", "true"); const [first, ...rest] = batchEligibilityData; if (rest.length > 0) { sessionStorage.setItem("chatbot_eligibility_queue", JSON.stringify(rest)); } prefillAndNavigate(first.memberId, first.dob, first.autoCheck); }; const handleEligibilityAndAppointment = async (targetDate) => { if (!eligibilityIdData) return; const dateLabel = targetDate ? new Date(targetDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "today"; addMsg("user", `Check eligibility & add to schedule (${dateLabel})`); if (!eligibilityIdData.patient) { addMsg("bot", `Running eligibility check — will add patient and create appointment for ${dateLabel} after...`); sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId, date: targetDate ?? null })); prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); return; } addMsg("bot", `Creating appointment for ${dateLabel}...`, true); try { const res = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: eligibilityIdData.patient.id, date: targetDate ?? undefined, }); const data = await res.json(); if (!res.ok) { replaceLastMsg(data.message ?? "Could not create appointment."); return; } replaceLastMsg(`Appointment added at ${data.startTime} (${data.column ?? "Column A"}) for ${data.dateLabel} — opening eligibility check page...`); prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); } catch { replaceLastMsg("Could not create appointment. Please try again."); } }; const handleCheckAndClaimRun = () => { if (!checkAndClaimData) return; addMsg("user", "Run check & claim"); addMsg("bot", "Opening the eligibility check page..."); // Store claim codes so the eligibility page can offer auto-claim after ACTIVE result sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({ codes: checkAndClaimData.matchedCodes, siteKey: checkAndClaimData.siteKey, patientId: checkAndClaimData.patient?.id ?? null, memberId: checkAndClaimData.memberId, dob: checkAndClaimData.dob, serviceDate: checkAndClaimData.serviceDate ?? null, renderingProvider: checkAndClaimData.renderingProvider ?? null, })); prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck); }; const handleFreeTextSubmit = async () => { const text = freeTextInput.trim(); if (!text || step === "ai-loading") return; setFreeTextInput(""); addMsg("user", text); addMsg("bot", "Thinking...", true); setStep("ai-loading"); try { const history = messages .filter((m) => !m.isLoading) .slice(-15) .map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text })); const d = new Date(); const clientDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history, clientDate }); const data = await res.json(); const claimActions = new Set(["claim_only_ready", "check_and_claim_ready", "need_appointment_selection"]); const attachmentSuffix = pendingFiles.length > 0 && claimActions.has(data.action) ? ` (📎 ${pendingFiles.length} attachment${pendingFiles.length > 1 ? "s" : ""} will be included)` : ""; replaceLastMsg((data.reply ?? "Sorry, I couldn't process that.") + attachmentSuffix); if (data.action === "navigate" && data.actionData?.url) { setTimeout(() => { setLocation(data.actionData.url); setOpen(false); resetStep(); }, 800); return; } if ((data.action === "check_eligibility_prefill" || data.action === "show_patient") && data.actionData?.patient) { setPatientResult(data.actionData.patient); setStep("patient-found"); return; } if (data.action === "batch_eligibility_ready" && data.actionData?.queue) { setBatchEligibilityData(data.actionData.queue); setStep("batch-eligibility-ready"); return; } if (data.action === "eligibility_id_ready" && data.actionData) { setEligibilityIdData({ memberId: data.actionData.memberId, dob: data.actionData.dob, siteKey: data.actionData.siteKey, autoCheck: data.actionData.autoCheck, patient: data.actionData.patient ?? null, appointmentDate: data.actionData.appointmentDate ?? null, }); setStep("eligibility-id-ready"); return; } if (data.action === "check_and_claim_ready" && data.actionData) { setCheckAndClaimData({ patient: data.actionData.patient ?? null, memberId: data.actionData.memberId, dob: data.actionData.dob, siteKey: data.actionData.siteKey, autoCheck: data.actionData.autoCheck, matchedCodes: data.actionData.matchedCodes ?? [], serviceDate: data.actionData.serviceDate ?? null, renderingProvider: data.actionData.renderingProvider ?? null, }); setStep("check-and-claim-ready"); return; } if (data.action === "need_insurance_clarification" && data.actionData) { setClarificationData({ memberId: data.actionData.memberId, dob: data.actionData.dob, patient: data.actionData.patient ?? null, procedureNames: data.actionData.procedureNames ?? [], options: data.actionData.options ?? [], }); setStep("need-insurance-clarification"); return; } if (data.action === "appointment_created") { setStep("menu"); return; } if (data.action === "need_appointment_selection" && data.actionData) { setApptSelectionData({ patient: data.actionData.patient, siteKey: data.actionData.siteKey, matchedCodes: data.actionData.matchedCodes ?? [], options: data.actionData.options ?? [], }); setStep("need-appointment-selection"); return; } if (data.action === "need_cdt_clarification" && data.actionData) { const phrases = data.actionData.unknownPhrases ?? []; const inputs = {}; for (const p of phrases) inputs[p] = ""; setCdtClarificationData({ unknownPhrases: phrases, codeInputs: inputs, originalMessage: text }); setStep("need-cdt-clarification"); return; } if (data.action === "batch_check_and_claim_ready" && data.actionData) { setBatchCheckAndClaimData({ queue: data.actionData.queue ?? [], matchedCodes: data.actionData.matchedCodes ?? [], renderingProvider: data.actionData.renderingProvider ?? null, }); setStep("batch-check-and-claim-ready"); return; } if (data.action === "batch_claim_ready" && data.actionData) { setBatchClaimData({ queue: data.actionData.queue ?? [], matchedCodes: data.actionData.matchedCodes ?? [], renderingProvider: data.actionData.renderingProvider ?? null, }); setStep("batch-claim-ready"); return; } if (data.action === "claim_only_ready" && data.actionData) { const { patient, matchedCodes, siteKey, serviceDate, appointmentId, renderingProvider } = data.actionData; setClaimReadyData({ patient: patient ?? null, matchedCodes: matchedCodes ?? [], siteKey, serviceDate, appointmentId: appointmentId ?? null, renderingProvider: renderingProvider ?? null, }); setStep("claim-ready"); return; } if (data.action === "preauth_ready" && data.actionData) { const { patient, matchedCodes, siteKey, serviceDate, renderingProvider } = data.actionData; setPreauthReadyData({ patient: patient ?? null, matchedCodes: matchedCodes ?? [], siteKey, serviceDate, renderingProvider: renderingProvider ?? null, }); setStep("preauth-ready"); return; } setStep("menu"); } catch { replaceLastMsg("Sorry, something went wrong. Please try again."); setStep("menu"); } }; const handleFreeTextKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleFreeTextSubmit(); } }; const showFreeTextInput = step === "menu" || step === "ai-loading" || step === "patient-found" || step === "eligibility-id-ready" || step === "batch-eligibility-ready" || step === "check-and-claim-ready" || step === "batch-claim-ready" || step === "batch-check-and-claim-ready" || step === "need-insurance-clarification" || step === "need-appointment-selection"; return (<> {open && (<> {/* Backdrop */}
{/* Chat panel */}{patientResult.firstName} {patientResult.lastName}
{patientResult.insuranceProvider && ({patientResult.insuranceProvider}
)} {patientResult.insuranceId && (ID: {patientResult.insuranceId}
)} {patientResult.dateOfBirth && (DOB: {patientResult.dateOfBirth}
)}{eligibilityIdData.patient.firstName} {eligibilityIdData.patient.lastName}
)}ID: {eligibilityIdData.memberId}
DOB: {eligibilityIdData.dob}
{eligibilityIdData.patient?.insuranceProvider && ({eligibilityIdData.patient.insuranceProvider}
)}{batchEligibilityData.length} patients to check:
{batchEligibilityData.map((item, i) => { const name = item.patient ? `${item.patient.firstName ?? ""} ${item.patient.lastName ?? ""}`.trim() : `ID: ${item.memberId}`; return ({i + 1}. {name} — DOB: {item.dob}
); })}{checkAndClaimData.patient.firstName} {checkAndClaimData.patient.lastName}
)}ID: {checkAndClaimData.memberId} · DOB: {checkAndClaimData.dob}
{checkAndClaimData.matchedCodes.length > 0 && (Claim after ACTIVE:
{checkAndClaimData.matchedCodes.map((c) => ({c.code} — {c.description}
))}Which insurance?
ID: {clarificationData.memberId}
Which appointment date?
Check & Claim for {batchCheckAndClaimData.queue.length} patients:
{batchCheckAndClaimData.queue.map((item, i) => { const name = item.patient ? `${item.patient.firstName ?? ""} ${item.patient.lastName ?? ""}`.trim() : `ID: ${item.memberId}`; return ({i + 1}. {name} — DOB: {item.dob}
); })} {batchCheckAndClaimData.matchedCodes.length > 0 && (Claim after ACTIVE:
{batchCheckAndClaimData.matchedCodes.map((c) => ({c.code} — {c.description}
))}Claim for {batchClaimData.queue.length} patients:
{batchClaimData.queue.map((item, i) => ({i + 1}. {item.patient.firstName} {item.patient.lastName}
))} {batchClaimData.matchedCodes.length > 0 && (Procedures:
{batchClaimData.matchedCodes.map((c) => ({c.code} — {c.description}
))}Confirm Claim
{claimReadyData.patient && ({claimReadyData.patient.firstName} {claimReadyData.patient.lastName}
)}Service date: {dateLabel}
{claimReadyData.matchedCodes.length > 0 && ({c.code} — {c.description}
))}Confirm Pre-Authorization
{preauthReadyData.patient && ({preauthReadyData.patient.firstName} {preauthReadyData.patient.lastName}
)}Tentative date: {dateLabel}
{preauthReadyData.matchedCodes.length > 0 && ({c.code}{c.toothNumber ? ` #${c.toothNumber}` : ""} — {c.description}
))}Unknown procedure term{cdtClarificationData.unknownPhrases.length > 1 ? "s" : ""}
Enter to send · Shift+Enter for new line · 📎 attach files
{ const files = Array.from(e.target.files ?? []); if (files.length > 0) { setPendingFiles((prev) => [...prev, ...files].slice(0, 5)); } e.target.value = ""; }}/>