feat: AI chat scheduling, claim automation, and session improvements

- Internal AI chat: schedule_appointment intent books earliest available
  slot in Column A using office hours; claim_only intent looks up latest
  past appointment for service date, asks user when two appointments are
  within 7 days, auto-triggers correct Selenium worker with mapped prices
- Gemini model updated to gemini-flash-latest; conversation history (15
  messages) passed for pronoun/reference resolution; history trimmed to
  start with user turn so Gemini doesn't reject the context
- Insurance alias file (insuranceAliases.json) replaces hardcoded siteKey
  matching; "tufs" now resolves to TUFTS_SCO
- DOB format normalized (MM/DD/YYYY → YYYY-MM-DD) before parseLocalDate;
  autoCheck now fires for all insurance types, not just MH/CMSP
- Claim form auto-submit: all handlers (MH, CCA, DDMA, UnitedDH, Tufts)
  accept formToUse and receive fee-schedule-priced form; prefillDone set
  after chatbot code prefill so autoSubmit gate opens correctly
- Chatbot: chat history persisted in sessionStorage, cleared on logout
  and auto-logout; Clear button writes fresh state synchronously; message
  history window increased to 15
- DentaQuest/TuftsSCO Selenium: "Remember me" checkbox clicked before
  sign-in to persist OTP trust cookie across sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-05 16:19:56 -04:00
parent ba2882957a
commit 1bbca38344
11 changed files with 693 additions and 94 deletions

View File

@@ -79,6 +79,8 @@ interface ClaimFormProps {
patientId: number;
appointmentId?: number;
autoSubmit?: boolean;
/** When set, autoSubmit triggers this insurance's Selenium worker instead of MH */
autoSubmitSiteKey?: string;
/** When true: form saves to AppointmentProcedure (Select Procedures flow), shows only Save button */
proceduresOnly?: boolean;
onSubmit: (data: ClaimFormData) => Promise<Claim>;
@@ -101,6 +103,7 @@ export function ClaimForm({
patientId,
appointmentId,
autoSubmit,
autoSubmitSiteKey,
proceduresOnly = false,
onHandleAppointmentSubmit,
onHandleUpdatePatient,
@@ -487,6 +490,41 @@ export function ClaimForm({
};
}, [appointmentId, serviceDate, existingClaimId]);
// Prefill service lines (and optional service date) from chatbot claim_only flow
useEffect(() => {
const raw = sessionStorage.getItem("chatbot_claim_prefill");
if (!raw) return;
try {
const { codes, serviceDate } = JSON.parse(raw) as {
codes: { code: string; description: string }[];
serviceDate?: string;
};
sessionStorage.removeItem("chatbot_claim_prefill");
if (!codes?.length) return;
if (serviceDate) {
try {
const d = parseLocalDate(serviceDate);
setServiceDateValue(d);
setServiceDate(formatLocalDate(d));
} catch {}
}
setForm((prev) => {
const date = serviceDate ? serviceDate : prev.serviceDate;
const updatedLines = [...prev.serviceLines];
codes.forEach((c, i) => {
if (i < updatedLines.length) {
updatedLines[i] = { ...updatedLines[i]!, procedureCode: c.code, procedureDate: date };
}
});
return { ...prev, serviceLines: updatedLines };
});
if (!appointmentId) setPrefillDone(true);
} catch {}
}, []);
// Restore NPI provider from saved procedures when npiProviders list loads after 2b
useEffect(() => {
if (!savedProcNpiId || !npiProviders.length) return;
@@ -949,10 +987,11 @@ export function ClaimForm({
};
// 3rd Button workflow — CCA Claim: saves to DB then submits via Selenium
const handleCCAClaim = async () => {
const handleCCAClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
const f = formToUse ?? form;
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!f.memberId?.trim()) missingFields.push("Member ID");
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
@@ -963,7 +1002,7 @@ export function ClaimForm({
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
const filteredServiceLines = (f.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
@@ -990,10 +1029,10 @@ export function ClaimForm({
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
filename: f.name,
mimeType: f.type,
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((file) => ({
filename: file.name,
mimeType: file.type,
}));
const selectedNpiProviderId = npiProvider?.npiNumber
@@ -1014,7 +1053,7 @@ export function ClaimForm({
// Send to CCA Selenium — send raw YYYY-MM-DD so Python _format_dob converts correctly
onHandleForCCASeleniumClaim({
...form,
...f,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
@@ -1028,10 +1067,11 @@ export function ClaimForm({
};
// Delta MA Claim: saves to DB then submits via Selenium
const handleDDMAClaim = async () => {
const handleDDMAClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
const f = formToUse ?? form;
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!f.memberId?.trim()) missingFields.push("Member ID");
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
@@ -1042,7 +1082,7 @@ export function ClaimForm({
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
const filteredServiceLines = (f.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
@@ -1068,7 +1108,7 @@ export function ClaimForm({
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
// Upload files to server so we get local filePaths for Selenium
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
@@ -1091,7 +1131,7 @@ export function ClaimForm({
});
onHandleForDDMASeleniumClaim({
...form,
...f,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
@@ -1106,10 +1146,11 @@ export function ClaimForm({
};
// United/DentalHub Claim: saves to DB then submits via Selenium
const handleUnitedDHClaim = async () => {
const handleUnitedDHClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
const f = formToUse ?? form;
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!f.memberId?.trim()) missingFields.push("Member ID");
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
@@ -1120,7 +1161,7 @@ export function ClaimForm({
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
const filteredServiceLines = (f.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
@@ -1146,7 +1187,7 @@ export function ClaimForm({
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(uploadedFiles)
@@ -1168,7 +1209,7 @@ export function ClaimForm({
});
onHandleForUnitedDHSeleniumClaim({
...form,
...f,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
@@ -1183,10 +1224,11 @@ export function ClaimForm({
};
// Tufts SCO Claim: saves to DB then submits via Selenium
const handleTuftsSCOClaim = async () => {
const handleTuftsSCOClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
const f = formToUse ?? form;
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!f.memberId?.trim()) missingFields.push("Member ID");
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
@@ -1197,7 +1239,7 @@ export function ClaimForm({
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
const filteredServiceLines = (f.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
@@ -1223,7 +1265,7 @@ export function ClaimForm({
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(uploadedFiles)
@@ -1255,7 +1297,7 @@ export function ClaimForm({
}
onHandleForTuftsSCOSeleniumClaim({
...form,
...f,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
@@ -1656,8 +1698,30 @@ export function ClaimForm({
if (autoSubmittedRef.current) return;
autoSubmittedRef.current = true;
handleMHSubmit();
}, [autoSubmit, prefillDone, isFormReady]);
// Apply fee-schedule prices before triggering so billed amounts are populated
const siteKeyForPricing = autoSubmitSiteKey
? autoSubmitSiteKey.replace(/_/g, "").toLowerCase()
: deriveInsuranceSiteKey(form.insuranceProvider || "");
const pricedForm = mapPricesForForm({
form: { ...form, insuranceSiteKey: siteKeyForPricing },
patientDOB: patient?.dateOfBirth ?? "",
insuranceSiteKey: siteKeyForPricing,
});
const key = (autoSubmitSiteKey ?? "").toLowerCase();
if (key === "tufts_sco" || key === "tuftsco" || key === "tufts sco") {
handleTuftsSCOClaim(pricedForm);
} else if (key === "cca") {
handleCCAClaim(pricedForm);
} else if (key === "ddma") {
handleDDMAClaim(pricedForm);
} else if (key === "united_sco" || key === "unitedco" || key === "dentalhub") {
handleUnitedDHClaim(pricedForm);
} else {
handleMHSubmit(pricedForm);
}
}, [autoSubmit, autoSubmitSiteKey, prefillDone, isFormReady]);
// overlay click handler (close when clicking backdrop)
const onOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {

View File

@@ -9,6 +9,7 @@ import {
MessageSquare,
Send,
Loader2,
RotateCcw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -24,7 +25,8 @@ type Step =
| "patient-found"
| "eligibility-id-ready"
| "check-and-claim-ready"
| "need-insurance-clarification";
| "need-insurance-clarification"
| "need-appointment-selection";
interface Message {
id: number;
@@ -96,14 +98,20 @@ function parseEligibilityInput(
};
}
const INITIAL_MESSAGES: Message[] = [
makeMsg("bot", "Hi! What can I help you with today?"),
];
const CHAT_STORAGE_KEY = "chatbot_messages";
function loadSavedMessages(): Message[] {
try {
const raw = sessionStorage.getItem(CHAT_STORAGE_KEY);
if (raw) return JSON.parse(raw) as Message[];
} catch {}
return [makeMsg("bot", "Hi! What can I help you with today?")];
}
export function ChatbotButton() {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<Step>("menu");
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [messages, setMessages] = useState<Message[]>(loadSavedMessages);
const [pasteInput, setPasteInput] = useState("");
const [parseError, setParseError] = useState("");
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
@@ -112,6 +120,12 @@ export function ChatbotButton() {
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null);
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
const [apptSelectionData, setApptSelectionData] = useState<{
patient: PatientResult;
siteKey: string;
matchedCodes: { code: string; description: string }[];
options: { label: string; appointmentId: number; serviceDate: string }[];
} | null>(null);
const [, setLocation] = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(null);
@@ -121,6 +135,14 @@ export function ChatbotButton() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, step]);
// Persist messages across navigation (cleared on logout)
useEffect(() => {
try {
const saveable = messages.filter((m) => !m.isLoading);
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable));
} catch {}
}, [messages]);
useEffect(() => {
if (step === "eligibility-input") {
setTimeout(() => pasteRef.current?.focus(), 50);
@@ -141,9 +163,9 @@ export function ChatbotButton() {
return next;
});
const reset = () => {
// Resets step/data only — keeps message history
const resetStep = () => {
setStep("menu");
setMessages([makeMsg("bot", "Hi! What can I help you with today?")]);
setPasteInput("");
setParseError("");
setEligibilityData(null);
@@ -152,22 +174,33 @@ export function ChatbotButton() {
setEligibilityIdData(null);
setCheckAndClaimData(null);
setClarificationData(null);
setApptSelectionData(null);
};
// Full reset including message history and stored session
const reset = () => {
resetStep();
const fresh = [makeMsg("bot", "Hi! What can I help you with today?")];
try {
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh));
} catch {}
setMessages(fresh);
};
const handleClose = () => {
setOpen(false);
reset();
resetStep();
};
const handleOptionSelect = (option: "eligibility" | "schedule" | "claims") => {
if (option === "schedule") {
addMsg("user", "Schedule an appointment");
addMsg("bot", "Opening the appointments page...");
setTimeout(() => { setLocation("/appointments"); setOpen(false); reset(); }, 600);
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); reset(); }, 600);
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:");
@@ -199,7 +232,7 @@ export function ChatbotButton() {
autoCheck: getAutoCheck(eligibilityData.dobISO),
}));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
};
const handleEligibilityFromPatient = () => {
@@ -214,13 +247,13 @@ export function ChatbotButton() {
}));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
}
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
};
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
};
const handleEligibilityIdRun = () => {
@@ -257,13 +290,17 @@ export function ChatbotButton() {
setStep("ai-loading");
try {
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text });
const history = messages
.filter((m) => !m.isLoading)
.slice(-15)
.map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history });
const data = await res.json();
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
if (data.action === "navigate" && data.actionData?.url) {
setTimeout(() => { setLocation(data.actionData.url); setOpen(false); reset(); }, 800);
setTimeout(() => { setLocation(data.actionData.url); setOpen(false); resetStep(); }, 800);
return;
}
@@ -313,6 +350,41 @@ export function ChatbotButton() {
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 === "claim_only_ready" && data.actionData) {
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
if (patient?.id && matchedCodes?.length > 0) {
sessionStorage.setItem(
"chatbot_claim_prefill",
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
);
}
const url = appointmentId
? `/claims?appointmentId=${appointmentId}`
: `/claims?newPatient=${patient?.id}`;
setTimeout(() => {
setLocation(url);
setOpen(false);
resetStep();
}, 600);
return;
}
setStep("menu");
} catch {
replaceLastMsg("Sorry, something went wrong. Please try again.");
@@ -333,7 +405,8 @@ export function ChatbotButton() {
step === "patient-found" ||
step === "eligibility-id-ready" ||
step === "check-and-claim-ready" ||
step === "need-insurance-clarification";
step === "need-insurance-clarification" ||
step === "need-appointment-selection";
return (
<>
@@ -360,13 +433,24 @@ export function ChatbotButton() {
<Bot className="h-4 w-4" />
<span className="font-semibold text-sm">Assistant</span>
</div>
<button
onClick={handleClose}
className="hover:opacity-70 transition-opacity rounded"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={reset}
className="flex items-center gap-1 text-xs hover:opacity-70 transition-opacity rounded px-1.5 py-0.5 border border-white/30 hover:bg-white/10"
title="Clear chat history"
>
<RotateCcw className="h-3 w-3" />
Clear
</button>
<button
onClick={handleClose}
className="hover:opacity-70 transition-opacity rounded"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Messages */}
@@ -631,6 +715,44 @@ export function ChatbotButton() {
</div>
)}
{/* Appointment selection */}
{step === "need-appointment-selection" && apptSelectionData && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-amber-800">Which appointment date?</p>
<div className="flex flex-col gap-1.5 pt-1">
{apptSelectionData.options.map((opt) => (
<button
key={opt.serviceDate}
className="text-left text-xs px-3 py-2 rounded-lg border border-amber-300 hover:bg-amber-100 transition-colors font-medium"
onClick={() => {
addMsg("user", opt.label);
addMsg("bot", `Using ${opt.label} as the service date.`);
sessionStorage.setItem(
"chatbot_claim_prefill",
JSON.stringify({
codes: apptSelectionData.matchedCodes,
siteKey: apptSelectionData.siteKey,
serviceDate: opt.serviceDate,
autoSubmit: true,
})
);
setTimeout(() => {
setLocation(`/claims?appointmentId=${opt.appointmentId}`);
setOpen(false);
resetStep();
}, 600);
}}
>
{opt.label}
</button>
))}
</div>
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={resetStep}>
Cancel
</Button>
</div>
)}
<div ref={messagesEndRef} />
</div>