feat: batch eligibility, batch claim, and batch check+claim from AI chat

- Add batch_eligibility, batch_claim, and batch_check_and_claim intents
  to AI classifier so multiple patients can be processed one by one
- Add queue processing on insurance-status and claims pages to auto-start
  the next patient after each check/claim completes
- Make patient schema firstName, lastName, phone optional so patients can
  be created with just member ID + DOB from eligibility checks
- Cancel buttons now preserve chat history instead of clearing it
- Patient-found card shows Check Eligibility, Eligibility & Appointment
  Today, and Cancel buttons
- Claim service date asks user to pick between latest appointment and
  today when they differ
- Login page subtitle styled with animated gradient

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-18 23:41:56 -04:00
parent a2e5c157ad
commit a52ff2d723
9 changed files with 710 additions and 66 deletions

View File

@@ -5,6 +5,9 @@ import { getLlm, type AiProvider } from "./llm-factory";
export type InternalChatIntent =
| "check_eligibility" // by patient name → look up in DB
| "eligibility_by_id" // by explicit memberId + dob (no name)
| "batch_eligibility" // multiple patients by memberId + dob
| "batch_claim" // claim same procedures for multiple patients by name
| "batch_check_and_claim" // eligibility + claim for multiple patients by memberId+dob
| "check_and_claim" // eligibility + claim procedures
| "find_patient" // look up patient record only
| "schedule_appointment" // add patient to today's (or specified) schedule
@@ -21,6 +24,10 @@ export interface ChatClassification {
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
memberId?: string; // for eligibility_by_id / check_and_claim
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
// --- batch eligibility (multiple patients) ---
patients?: { memberId: string; dob: string }[]; // for batch_eligibility
// --- batch claim (same procedures for multiple patients by name) ---
patientNames?: string[]; // for batch_claim
// --- insurance hint (only if explicitly stated in the message) ---
insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA"
// --- rendering/treating provider (only if explicitly stated, e.g. "with provider Kai Gao") ---
@@ -48,6 +55,8 @@ Respond ONLY with valid JSON (no markdown fences):
"patientName": "<full name if mentioned by name>",
"memberId": "<member/insurance ID if given explicitly or found in history>",
"dob": "<date of birth in MM/DD/YYYY if given explicitly or found in history>",
"patients": [{"memberId": "<id>", "dob": "<MM/DD/YYYY>"}, ...],
"patientNames": ["<name1>", "<name2>", ...],
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"renderingProvider": "<provider/doctor name only if explicitly stated, e.g. 'Kai Gao', 'Dr. Smith' — omit if not mentioned>",
"procedureNames": ["<raw procedure name>", ...],
@@ -61,12 +70,27 @@ Omit any field that is not present in the message or history.
Intents:
- check_eligibility : user wants to check insurance for a patient identified by NAME only
e.g. "check Maria Jesus", "verify insurance for John Smith"
- eligibility_by_id : user provides a member ID and date of birth (no patient name)
- eligibility_by_id : user provides a SINGLE member ID and date of birth (no patient name)
e.g. "check masshealth for 100xxxx, 10/10/1988"
ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
In these cases set appointmentDate to the mentioned date (YYYY-MM-DD)
- batch_eligibility : user provides MULTIPLE member IDs with dates of birth in one message
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000"
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000"
Use this ONLY when TWO OR MORE distinct memberId+dob pairs are given.
Put each pair into the "patients" array. Also set insuranceHint if stated.
- batch_claim : user wants to claim the SAME procedures for MULTIPLE patients identified by NAME
e.g. "claim perio exam and adult prophy for Jackaline and Keioson"
e.g. "perio exam, adult cleaning for Maria and John"
Use this ONLY when procedures AND two or more patient names are given.
Put each patient name into the "patientNames" array. Put procedure names in "procedureNames".
- batch_check_and_claim : user provides MULTIPLE member IDs with DOBs AND wants to claim PROCEDURES for all of them
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000, and claim perio exam and adult prophy"
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000 and claim D0120 D1110 for them"
Use this when TWO OR MORE memberId+dob pairs are given WITH procedures.
Put each pair into "patients" array. Put procedure names in "procedureNames".
- check_and_claim : user wants to check eligibility AND submit PROCEDURES/BILLING as claims
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
e.g. "check Maria Jesus and claim D0120 D1110"

View File

@@ -45,6 +45,9 @@ export interface ChatResponse {
| "show_patient"
| "check_eligibility_prefill"
| "eligibility_id_ready"
| "batch_eligibility_ready"
| "batch_claim_ready"
| "batch_check_and_claim_ready"
| "check_and_claim_ready"
| "need_insurance_clarification"
| "appointment_created"
@@ -314,12 +317,30 @@ export async function runInternalChatWorkflow(
return await handleEligibilityById(classification, storage);
}
// ── Batch eligibility (multiple patients) ─────────────────────────────────
if (intent === "batch_eligibility") {
return await handleBatchEligibility(classification, storage);
}
// ── Check eligibility + claim procedures ──────────────────────────────────
if (intent === "check_and_claim") {
return await handleCheckAndClaim(classification, storage, customAliases);
}
// ── Batch check & claim (eligibility + claim for multiple patients) ────────
if (intent === "batch_check_and_claim") {
return await handleBatchCheckAndClaim(classification, storage, customAliases);
}
// ── Batch claim (same procedures for multiple patients) ───────────────────
if (intent === "batch_claim") {
return await handleBatchClaim(classification, storage, customAliases);
}
// ── Claim only (no eligibility check) ─────────────────────────────────────
if (intent === "claim_only") {
@@ -436,6 +457,266 @@ async function handleEligibilityById(
};
}
// ─── batch_eligibility ───────────────────────────────────────────────────────
async function handleBatchEligibility(
c: ChatClassification,
storage: StorageLike
): Promise<ChatResponse> {
const pairs = c.patients ?? [];
if (pairs.length < 2) {
// Fallback to single if somehow only 1 pair
if (pairs.length === 1) {
return await handleEligibilityById(
{ ...c, memberId: pairs[0]!.memberId, dob: pairs[0]!.dob, intent: "eligibility_by_id" },
storage
);
}
return { reply: "Please provide at least two member ID + DOB pairs to batch-check." };
}
const resolved: {
memberId: string;
dob: string;
siteKey: string;
autoCheck: string;
patient: ResolvedPatient | null;
}[] = [];
for (const { memberId, dob } of pairs) {
const id = memberId?.trim();
const d = dob?.trim();
if (!id || !d) continue;
const existing = await findPatientByMemberId(id, d, storage);
const patient: ResolvedPatient | null = existing ? patientToResult(existing) : null;
const resolvedDob = d ?? patient?.dateOfBirth ?? null;
if (!resolvedDob) continue;
const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null,
c.insuranceHint ?? null
) ?? "MH";
resolved.push({
memberId: id,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob),
patient,
});
}
if (resolved.length === 0) {
return { reply: "Could not parse any valid member ID + DOB pairs." };
}
const labels = resolved.map((r) => {
const name = r.patient
? `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim()
: `ID ${r.memberId}`;
return name;
});
return {
reply: `Ready to check eligibility for ${resolved.length} patients: ${labels.join(", ")}.`,
action: "batch_eligibility_ready",
actionData: { queue: resolved },
};
}
// ─── batch_check_and_claim ────────────────────────────────────────────────────
async function handleBatchCheckAndClaim(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
const pairs = c.patients ?? [];
if (pairs.length < 2) {
if (pairs.length === 1) {
return await handleCheckAndClaim(
{ ...c, memberId: pairs[0]!.memberId, dob: pairs[0]!.dob, intent: "check_and_claim" },
storage,
customAliases
);
}
return { reply: "Please provide at least two member ID + DOB pairs." };
}
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
if (procedureNames.length === 0) {
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
}
const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases);
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
if (unmatched.length > 0) {
const phrases = unmatched.map((r) => r.input);
return {
reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`,
action: "need_cdt_clarification",
actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) },
};
}
const resolved: {
memberId: string;
dob: string;
siteKey: string;
autoCheck: string;
patient: ResolvedPatient | null;
}[] = [];
for (const { memberId, dob } of pairs) {
const id = memberId?.trim();
const d = dob?.trim();
if (!id || !d) continue;
const existing = await findPatientByMemberId(id, d, storage);
const patient: ResolvedPatient | null = existing ? patientToResult(existing) : null;
const resolvedDob = d ?? patient?.dateOfBirth ?? null;
if (!resolvedDob) continue;
const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null,
c.insuranceHint ?? null
) ?? "MH";
resolved.push({
memberId: id,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob),
patient,
});
}
if (resolved.length === 0) {
return { reply: "Could not parse any valid member ID + DOB pairs." };
}
const labels = resolved.map((r) => {
const name = r.patient
? `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim()
: `ID ${r.memberId}`;
return name;
});
const codeList = matched.map((r) => `${r.code} (${r.description})`).join(", ");
return {
reply: `Ready to check eligibility and claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`,
action: "batch_check_and_claim_ready",
actionData: {
queue: resolved,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
renderingProvider: c.renderingProvider ?? null,
},
};
}
// ─── batch_claim ─────────────────────────────────────────────────────────────
async function handleBatchClaim(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
const names = c.patientNames ?? [];
if (names.length < 2) {
if (names.length === 1) {
return await handleClaimOnly(
{ ...c, patientName: names[0], intent: "claim_only" },
storage,
customAliases
);
}
return { reply: "Please include at least two patient names to batch-claim." };
}
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
if (procedureNames.length === 0) {
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
}
const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases);
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
if (unmatched.length > 0) {
const phrases = unmatched.map((r) => r.input);
return {
reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`,
action: "need_cdt_clarification",
actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) },
};
}
const resolved: {
patient: ResolvedPatient;
siteKey: string;
serviceDate: string | null;
appointmentId: number | null;
}[] = [];
const notFound: string[] = [];
for (const name of names) {
const trimmed = name.trim();
if (!trimmed) continue;
const raw = await findPatientByName(trimmed, storage);
if (!raw) {
notFound.push(trimmed);
continue;
}
const patient = patientToResult(raw);
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate);
if (d1120Warning) return d1120Warning;
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
let serviceDate: string | null = c.appointmentDate ?? null;
let appointmentId: number | null = null;
if (!serviceDate) {
const now = new Date();
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
resolved.push({ patient, siteKey, serviceDate, appointmentId });
}
if (notFound.length > 0 && resolved.length === 0) {
return { reply: `Could not find any patients matching: ${notFound.join(", ")}. Please check the spelling.` };
}
const labels = resolved.map((r) =>
`${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim()
);
const codeList = matched.map((r) => `${r.code} (${r.description})`).join(", ");
let reply = `Ready to claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`;
if (notFound.length > 0) {
reply += ` Could not find: ${notFound.join(", ")}.`;
}
return {
reply,
action: "batch_claim_ready",
actionData: {
queue: resolved.map((r) => ({
patient: r.patient,
siteKey: r.siteKey,
serviceDate: r.serviceDate,
appointmentId: r.appointmentId,
})),
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
renderingProvider: c.renderingProvider ?? null,
},
};
}
// ─── check_and_claim ─────────────────────────────────────────────────────────
async function handleCheckAndClaim(
@@ -624,53 +905,44 @@ async function handleClaimOnly(
let appointmentId: number | null = null;
if (!serviceDate) {
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const appts = await storage.getAppointmentsByPatientId(patient.id);
const sorted = appts.sort((a: any, b: any) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
if (sorted.length >= 2) {
const d1 = new Date(sorted[0].date).getTime();
const d2 = new Date(sorted[1].date).getTime();
const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
if (sorted.length > 0) {
const rawDate = new Date(sorted[0].date);
const latestStr = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`;
if (diffDays < 7) {
// Use UTC methods to avoid local-timezone day-shift on midnight UTC dates
if (latestStr === todayStr) {
serviceDate = todayStr;
appointmentId = sorted[0].id ?? null;
} else {
const fmtUTC = (a: any) => {
const d = new Date(a.date);
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
};
const isoUTC = (a: any) => {
const d = new Date(a.date);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
};
const todayLabel = `${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}/${now.getFullYear()}`;
return {
reply: `Found two appointments close together for ${fullName}: ${fmtUTC(sorted[0])} and ${fmtUTC(sorted[1])}. Which date should I use for the claim?`,
reply: `Which service date for ${fullName}?`,
action: "need_appointment_selection",
actionData: {
patient,
siteKey: resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH",
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })),
options: [
{ label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: isoUTC(sorted[0]) },
{ label: fmtUTC(sorted[1]), appointmentId: sorted[1].id, serviceDate: isoUTC(sorted[1]) },
{ label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: latestStr },
{ label: `${todayLabel} (Today)`, appointmentId: null, serviceDate: todayStr },
],
},
};
}
} else {
serviceDate = todayStr;
}
if (sorted.length > 0) {
const rawDate = new Date(sorted[0].date);
serviceDate = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`;
appointmentId = sorted[0].id ?? null;
}
}
if (!serviceDate) {
// No appointment on file and no date in message — default to today
const now = new Date();
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";

View File

@@ -137,7 +137,6 @@ export async function createOrUpdatePatientByInsuranceId(options: {
updates.firstName = incomingFirst;
if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast)
updates.lastName = incomingLast;
// Store DOB if not already set
if (dobDate && !patient.dateOfBirth) updates.dateOfBirth = dobDate;
if (Object.keys(updates).length > 0) {
console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, updates);
@@ -163,15 +162,8 @@ export async function createOrUpdatePatientByInsuranceId(options: {
try {
patientData = insertPatientSchema.parse(createPayload);
} catch (e1) {
console.warn(`[createOrUpdatePatient] schema parse failed (attempt 1):`, e1);
const safePayload = { ...createPayload };
delete safePayload.dateOfBirth;
try {
patientData = insertPatientSchema.parse(safePayload);
} catch (e2) {
console.warn(`[createOrUpdatePatient] schema parse failed (attempt 2):`, e2);
patientData = safePayload as InsertPatient;
}
console.warn(`[createOrUpdatePatient] schema parse failed:`, e1);
patientData = createPayload as InsertPatient;
}
try {

View File

@@ -26,11 +26,14 @@ type Step =
| "ai-loading"
| "patient-found"
| "eligibility-id-ready"
| "batch-eligibility-ready"
| "check-and-claim-ready"
| "need-insurance-clarification"
| "need-appointment-selection"
| "need-cdt-clarification"
| "claim-ready"
| "batch-claim-ready"
| "batch-check-and-claim-ready"
| "preauth-ready";
interface Message {
@@ -160,6 +163,7 @@ export function ChatbotButton() {
const [freeTextInput, setFreeTextInput] = useState("");
const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; appointmentDate?: string | null } | null>(null);
const [batchEligibilityData, setBatchEligibilityData] = 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<{
@@ -181,6 +185,16 @@ export function ChatbotButton() {
appointmentId: number | null;
renderingProvider: string | null;
} | null>(null);
const [batchCheckAndClaimData, setBatchCheckAndClaimData] = useState<{
queue: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[];
matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[];
renderingProvider: string | null;
} | null>(null);
const [batchClaimData, setBatchClaimData] = useState<{
queue: { patient: PatientResult; siteKey: string; serviceDate: string | null; appointmentId: number | null }[];
matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[];
renderingProvider: string | null;
} | null>(null);
const [preauthReadyData, setPreauthReadyData] = useState<{
patient: PatientResult | null;
matchedCodes: { code: string; description: string; toothNumber?: string }[];
@@ -257,11 +271,14 @@ export function ChatbotButton() {
setFreeTextInput("");
setPatientResult(null);
setEligibilityIdData(null);
setBatchEligibilityData(null);
setCheckAndClaimData(null);
setClarificationData(null);
setApptSelectionData(null);
setCdtClarificationData(null);
setClaimReadyData(null);
setBatchClaimData(null);
setBatchCheckAndClaimData(null);
setPreauthReadyData(null);
setPendingFiles([]);
};
@@ -326,18 +343,30 @@ export function ChatbotButton() {
};
const handleEligibilityFromPatient = () => {
if (!patientResult) return;
if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return;
addMsg("user", "Check eligibility now");
addMsg("bot", "Opening the eligibility check page...");
if (patientResult.insuranceId && patientResult.dateOfBirth) {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({
memberId: patientResult.insuranceId,
dob: patientResult.dateOfBirth,
autoCheck: getAutoCheck(patientResult.dateOfBirth),
}));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
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...");
}
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth));
};
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
@@ -354,6 +383,17 @@ export function ChatbotButton() {
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 handleEligibilityAndAppointment = async (targetDate?: string) => {
if (!eligibilityIdData) return;
const dateLabel = targetDate
@@ -445,6 +485,12 @@ export function ChatbotButton() {
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,
@@ -510,6 +556,26 @@ export function ChatbotButton() {
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({
@@ -556,7 +622,10 @@ export function ChatbotButton() {
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";
@@ -684,7 +753,7 @@ export function ChatbotButton() {
>
Continue <ChevronRight className="h-3 w-3 ml-1" />
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>
Cancel
</Button>
</div>
@@ -726,19 +795,29 @@ export function ChatbotButton() {
{patientResult.dateOfBirth && (
<p className="text-xs text-gray-500">DOB: {patientResult.dateOfBirth}</p>
)}
<div className="flex gap-2 pt-1">
{patientResult.insuranceId && (
<Button
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90"
onClick={handleEligibilityFromPatient}
>
<Stethoscope className="h-3 w-3 mr-1" />
Check Eligibility
</Button>
<div className="flex flex-col gap-2 pt-1">
{patientResult.insuranceId && patientResult.dateOfBirth && (
<>
<Button
size="sm"
className="w-full h-8 text-xs bg-primary hover:bg-primary/90"
onClick={handleEligibilityFromPatient}
>
<Stethoscope className="h-3 w-3 mr-1" />
Check Eligibility
</Button>
<Button
size="sm"
className="w-full h-8 text-xs bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleEligibilityAndAppointmentFromPatient}
>
<Calendar className="h-3 w-3 mr-1" />
Eligibility &amp; Appointment Today
</Button>
</>
)}
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
Done
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
Cancel
</Button>
</div>
</div>
@@ -785,7 +864,39 @@ export function ChatbotButton() {
{new Date(eligibilityIdData.appointmentDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</Button>
)}
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={reset}>
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
Cancel
</Button>
</div>
</div>
)}
{/* Batch eligibility ready */}
{step === "batch-eligibility-ready" && batchEligibilityData && (
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-indigo-800">
{batchEligibilityData.length} patients to check:
</p>
{batchEligibilityData.map((item, i) => {
const name = item.patient
? `${item.patient.firstName ?? ""} ${item.patient.lastName ?? ""}`.trim()
: `ID: ${item.memberId}`;
return (
<p key={i} className="text-xs text-indigo-600 pl-2">
{i + 1}. {name} DOB: {item.dob}
</p>
);
})}
<div className="flex flex-col gap-2 pt-1">
<Button
size="sm"
className="w-full h-8 text-xs bg-indigo-600 hover:bg-indigo-700 text-white"
onClick={handleBatchEligibilityRun}
>
<Stethoscope className="h-3 w-3 mr-1" />
Check All ({batchEligibilityData.length} patients)
</Button>
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
Cancel
</Button>
</div>
@@ -820,7 +931,7 @@ export function ChatbotButton() {
<Stethoscope className="h-3 w-3 mr-1" />
Check &amp; Claim
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>
Cancel
</Button>
</div>
@@ -884,7 +995,7 @@ export function ChatbotButton() {
</button>
))}
</div>
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={reset}>
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={resetStep}>
Cancel
</Button>
</div>
@@ -931,6 +1042,135 @@ export function ChatbotButton() {
</div>
)}
{/* Batch check & claim ready */}
{step === "batch-check-and-claim-ready" && batchCheckAndClaimData && (
<div className="bg-purple-50 border border-purple-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-purple-800">
Check &amp; Claim for {batchCheckAndClaimData.queue.length} patients:
</p>
{batchCheckAndClaimData.queue.map((item, i) => {
const name = item.patient
? `${item.patient.firstName ?? ""} ${item.patient.lastName ?? ""}`.trim()
: `ID: ${item.memberId}`;
return (
<p key={i} className="text-xs text-purple-600 pl-2">
{i + 1}. {name} DOB: {item.dob}
</p>
);
})}
{batchCheckAndClaimData.matchedCodes.length > 0 && (
<div className="space-y-0.5 pt-0.5">
<p className="text-xs font-medium text-purple-700">Claim after ACTIVE:</p>
{batchCheckAndClaimData.matchedCodes.map((c) => (
<p key={c.code} className="text-xs text-gray-700 pl-2">
<span className="font-medium">{c.code}</span> {c.description}
</p>
))}
</div>
)}
<div className="flex flex-col gap-2 pt-1">
<Button
size="sm"
className="w-full h-8 text-xs bg-purple-600 hover:bg-purple-700 text-white"
onClick={() => {
const { queue, matchedCodes, renderingProvider } = batchCheckAndClaimData;
addMsg("user", `Check & claim all ${queue.length} patients`);
addMsg("bot", `Checking eligibility and claiming for ${queue.length} patients one by one...`);
const [first, ...rest] = queue;
if (rest.length > 0) {
sessionStorage.setItem("chatbot_check_claim_queue", JSON.stringify({
remaining: rest,
matchedCodes,
renderingProvider,
}));
}
sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({
codes: matchedCodes,
siteKey: first!.siteKey,
patientId: first!.patient?.id ?? null,
memberId: first!.memberId,
dob: first!.dob,
serviceDate: null,
renderingProvider,
}));
prefillAndNavigate(first!.memberId, first!.dob, first!.autoCheck);
}}
>
<Stethoscope className="h-3 w-3 mr-1" />
Check &amp; Claim All ({batchCheckAndClaimData.queue.length} patients)
</Button>
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
Cancel
</Button>
</div>
</div>
)}
{/* Batch claim ready */}
{step === "batch-claim-ready" && batchClaimData && (
<div className="bg-orange-50 border border-orange-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-orange-800">
Claim for {batchClaimData.queue.length} patients:
</p>
{batchClaimData.queue.map((item, i) => (
<p key={i} className="text-xs text-orange-600 pl-2">
{i + 1}. {item.patient.firstName} {item.patient.lastName}
</p>
))}
{batchClaimData.matchedCodes.length > 0 && (
<div className="space-y-0.5 pt-0.5">
<p className="text-xs font-medium text-orange-700">Procedures:</p>
{batchClaimData.matchedCodes.map((c) => (
<p key={c.code} className="text-xs text-gray-700 pl-2">
<span className="font-medium">{c.code}</span> {c.description}
</p>
))}
</div>
)}
<div className="flex flex-col gap-2 pt-1">
<Button
size="sm"
className="w-full h-8 text-xs bg-orange-600 hover:bg-orange-700 text-white"
onClick={() => {
const { queue, matchedCodes, renderingProvider } = batchClaimData;
addMsg("user", `Claim all ${queue.length} patients`);
addMsg("bot", `Claiming ${queue.length} patients one by one...`);
const [first, ...rest] = queue;
if (rest.length > 0) {
sessionStorage.setItem("chatbot_claim_queue", JSON.stringify({
remaining: rest,
matchedCodes,
renderingProvider,
}));
}
if (first!.patient.id && matchedCodes.length > 0) {
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({
codes: matchedCodes,
siteKey: first!.siteKey,
serviceDate: first!.serviceDate,
autoSubmit: true,
renderingProvider: renderingProvider ?? null,
dob: first!.patient.dateOfBirth ?? null,
}));
}
setChatbotPendingFiles(pendingFiles);
markJobStarted();
const url = first!.appointmentId
? `/claims?appointmentId=${first!.appointmentId}`
: `/claims?newPatient=${first!.patient.id}`;
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
}}
>
<FileText className="h-3 w-3 mr-1" />
Claim All ({batchClaimData.queue.length} patients)
</Button>
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
Cancel
</Button>
</div>
</div>
)}
{/* Claim ready — confirm before submitting */}
{step === "claim-ready" && claimReadyData && (() => {
const [sy, sm, sd] = (claimReadyData.serviceDate ?? "").split("-");

View File

@@ -110,3 +110,13 @@
min-width: 6rem; /* Prevent shrinking */
appearance: none; /* Removes native styling */
}
@keyframes gradient-flash {
0% { background-position: 100% 50%; }
50% { background-position: 0% 50%; }
100% { background-position: 100% 50%; }
}
.animate-gradient-flash {
animation: gradient-flash 6s ease-in-out infinite;
}

View File

@@ -90,8 +90,9 @@ export default function AuthPage() {
<h1 className="text-3xl font-bold text-primary mb-3 tracking-tight">
My Dental Office Management
</h1>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
Driven by multiple AI agents
<p className="text-sm font-semibold tracking-[0.15em] bg-clip-text text-transparent animate-gradient-flash"
style={{ backgroundImage: "linear-gradient(90deg, #7c3aed, #d946ef, #06b6d4, #7c3aed)", backgroundSize: "200% 100%" }}>
Driven By Multiple AI Agents
</p>
</div>

View File

@@ -911,6 +911,79 @@ export default function ClaimsPage() {
}
}
} catch {}
// If a chatbot batch check+claim queue is pending, navigate to eligibility for the next patient
try {
const raw = sessionStorage.getItem("chatbot_check_claim_queue");
if (raw) {
const parsed = JSON.parse(raw);
const remaining = parsed?.remaining as any[] | undefined;
const matchedCodes = parsed?.matchedCodes ?? [];
const renderingProvider = parsed?.renderingProvider ?? null;
if (remaining && remaining.length > 0) {
const [next, ...rest] = remaining;
if (rest.length > 0) {
sessionStorage.setItem("chatbot_check_claim_queue", JSON.stringify({ remaining: rest, matchedCodes, renderingProvider }));
} else {
sessionStorage.removeItem("chatbot_check_claim_queue");
}
sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({
codes: matchedCodes,
siteKey: next.siteKey,
patientId: next.patient?.id ?? null,
memberId: next.memberId,
dob: next.dob,
serviceDate: null,
renderingProvider,
}));
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({
memberId: next.memberId,
dob: next.dob,
autoCheck: next.autoCheck,
}));
setTimeout(() => {
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
setWouterLocation("/insurance-status");
}, 500);
return;
}
sessionStorage.removeItem("chatbot_check_claim_queue");
}
} catch {}
// If a chatbot batch claim queue is pending, open the next patient
try {
const raw = sessionStorage.getItem("chatbot_claim_queue");
if (raw) {
const parsed = JSON.parse(raw);
const remaining = parsed?.remaining as any[] | undefined;
const matchedCodes = parsed?.matchedCodes ?? [];
const renderingProvider = parsed?.renderingProvider ?? null;
if (remaining && remaining.length > 0) {
const [next, ...rest] = remaining;
if (rest.length > 0) {
sessionStorage.setItem("chatbot_claim_queue", JSON.stringify({ remaining: rest, matchedCodes, renderingProvider }));
} else {
sessionStorage.removeItem("chatbot_claim_queue");
}
if (next.patient?.id && matchedCodes.length > 0) {
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({
codes: matchedCodes,
siteKey: next.siteKey,
serviceDate: next.serviceDate,
autoSubmit: true,
renderingProvider,
dob: next.patient.dateOfBirth ?? null,
}));
}
setTimeout(() => {
setWouterLocation(`/claims?newPatient=${next.patient.id}`);
}, 500);
return;
}
sessionStorage.removeItem("chatbot_claim_queue");
}
} catch {}
};
// Pre Auth section

View File

@@ -385,6 +385,7 @@ export default function InsuranceStatusPage() {
setPreviewFallbackFilename(jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`);
setPreviewOpen(true);
}
processNextInQueue();
} catch (error: any) {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" }));
toast({ title: "Selenium service error", description: error.message || "An error occurred.", variant: "destructive" });
@@ -574,6 +575,7 @@ export default function InsuranceStatusPage() {
if (claimed) return;
setSelectedPatient(null);
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
processNextInQueue();
// Open all PDFs side by side in the modal
if (jobResult.pdfFileId || jobResult.memberDetailsPdfFileId || jobResult.historyPdfFileId) {
@@ -643,6 +645,7 @@ export default function InsuranceStatusPage() {
setSelectedPatient(null);
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
processNextInQueue();
// Open 4-panel modal
if (jobResult.pdfFileId || jobResult.memberDetailsPdfFileId || jobResult.historyPdfFileId || jobResult.accumulatorPdfFileId) {
@@ -773,6 +776,29 @@ export default function InsuranceStatusPage() {
} catch {}
};
const processNextInQueue = () => {
try {
const raw = sessionStorage.getItem("chatbot_eligibility_queue");
if (!raw) return;
const queue = JSON.parse(raw) as { memberId: string; dob: string; autoCheck: string }[];
if (!queue.length) {
sessionStorage.removeItem("chatbot_eligibility_queue");
return;
}
const [next, ...rest] = queue;
if (rest.length > 0) {
sessionStorage.setItem("chatbot_eligibility_queue", JSON.stringify(rest));
} else {
sessionStorage.removeItem("chatbot_eligibility_queue");
}
toast({ title: `${rest.length + 1} patient${rest.length > 0 ? "s" : ""} remaining`, description: `Starting next: ${next!.memberId}` });
setTimeout(() => {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId: next!.memberId, dob: next!.dob, autoCheck: next!.autoCheck }));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
}, 1500);
} catch {}
};
// Redirect from schedule page "Check Eligibility": prefill patient + optionally auto-trigger or scroll
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -1018,6 +1044,7 @@ export default function InsuranceStatusPage() {
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`);
setPreviewOpen(true);
}
processNextInQueue();
}}
/>
</div>
@@ -1039,6 +1066,7 @@ export default function InsuranceStatusPage() {
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`);
setPreviewOpen(true);
}
processNextInQueue();
}}
/>
</div>
@@ -1057,6 +1085,7 @@ export default function InsuranceStatusPage() {
fallbackFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`,
);
setPreviewOpen(true);
processNextInQueue();
}}
/>
</div>
@@ -1081,6 +1110,7 @@ export default function InsuranceStatusPage() {
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`);
setPreviewOpen(true);
}
processNextInQueue();
}}
/>
</div>
@@ -1102,6 +1132,7 @@ export default function InsuranceStatusPage() {
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
setPreviewOpen(true);
}
processNextInQueue();
}}
/>
</div>
@@ -1123,6 +1154,7 @@ export default function InsuranceStatusPage() {
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_cca_${memberId}.pdf`);
setPreviewOpen(true);
}
processNextInQueue();
}}
/>
</div>

View File

@@ -51,14 +51,14 @@ export const insertPatientSchema = (
createdAt: true,
})
.extend({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
firstName: z.string().optional().default(""),
lastName: z.string().optional().default(""),
dateOfBirth: z.preprocess(
(val) => (val === null || val === undefined || val === "" ? undefined : val),
z.coerce.date({ required_error: "Date of birth is required" })
),
gender: z.string().optional().nullable(),
phone: z.string().min(1, "Phone number is required"),
phone: z.string().optional().nullable(),
insuranceId: insuranceIdSchema,
});