From a52ff2d723961be9bae837d008a03d5d627abb7b Mon Sep 17 00:00:00 2001 From: ff Date: Thu, 18 Jun 2026 23:41:56 -0400 Subject: [PATCH] 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 --- apps/Backend/src/ai/internal-chat-graph.ts | 26 +- apps/Backend/src/ai/internal-chat-workflow.ts | 322 ++++++++++++++++-- apps/Backend/src/queue/processors/_shared.ts | 12 +- .../src/components/layout/chatbot.tsx | 290 ++++++++++++++-- apps/Frontend/src/index.css | 10 + apps/Frontend/src/pages/auth-page.tsx | 5 +- apps/Frontend/src/pages/claims-page.tsx | 73 ++++ .../src/pages/insurance-status-page.tsx | 32 ++ packages/db/types/patient-types.ts | 6 +- 9 files changed, 710 insertions(+), 66 deletions(-) diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index 2f4cf692..6eff3b24 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -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": "", "memberId": "", "dob": "", + "patients": [{"memberId": "", "dob": ""}, ...], + "patientNames": ["", "", ...], "insuranceHint": "", "renderingProvider": "", "procedureNames": ["", ...], @@ -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" diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index e68a14a8..1d172739 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -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 { + 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 { + 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 { + 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"; diff --git a/apps/Backend/src/queue/processors/_shared.ts b/apps/Backend/src/queue/processors/_shared.ts index 8d1059f4..559a8a19 100644 --- a/apps/Backend/src/queue/processors/_shared.ts +++ b/apps/Backend/src/queue/processors/_shared.ts @@ -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 { diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index 762dedbd..af590e6a 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -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(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(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 - @@ -726,19 +795,29 @@ export function ChatbotButton() { {patientResult.dateOfBirth && (

DOB: {patientResult.dateOfBirth}

)} -
- {patientResult.insuranceId && ( - +
+ {patientResult.insuranceId && patientResult.dateOfBirth && ( + <> + + + )} -
@@ -785,7 +864,39 @@ export function ChatbotButton() { {new Date(eligibilityIdData.appointmentDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} )} - + + + )} + + {/* Batch eligibility ready */} + {step === "batch-eligibility-ready" && batchEligibilityData && ( +
+

+ {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} +

+ ); + })} +
+ +
@@ -820,7 +931,7 @@ export function ChatbotButton() { Check & Claim -
@@ -884,7 +995,7 @@ export function ChatbotButton() { ))} - @@ -931,6 +1042,135 @@ export function ChatbotButton() { )} + {/* Batch check & claim ready */} + {step === "batch-check-and-claim-ready" && batchCheckAndClaimData && ( +
+

+ 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} +

+ ))} +
+ )} +
+ + +
+
+ )} + + {/* Batch claim ready */} + {step === "batch-claim-ready" && batchClaimData && ( +
+

+ 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} +

+ ))} +
+ )} +
+ + +
+
+ )} + {/* Claim ready — confirm before submitting */} {step === "claim-ready" && claimReadyData && (() => { const [sy, sm, sd] = (claimReadyData.serviceDate ?? "").split("-"); diff --git a/apps/Frontend/src/index.css b/apps/Frontend/src/index.css index 3abb2aba..8ea7ad73 100755 --- a/apps/Frontend/src/index.css +++ b/apps/Frontend/src/index.css @@ -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; +} diff --git a/apps/Frontend/src/pages/auth-page.tsx b/apps/Frontend/src/pages/auth-page.tsx index dd25f018..0fb27086 100755 --- a/apps/Frontend/src/pages/auth-page.tsx +++ b/apps/Frontend/src/pages/auth-page.tsx @@ -90,8 +90,9 @@ export default function AuthPage() {

My Dental Office Management

-

- Driven by multiple AI agents +

+ Driven By Multiple AI Agents

diff --git a/apps/Frontend/src/pages/claims-page.tsx b/apps/Frontend/src/pages/claims-page.tsx index 0a852fc2..00e9a558 100755 --- a/apps/Frontend/src/pages/claims-page.tsx +++ b/apps/Frontend/src/pages/claims-page.tsx @@ -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 diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 3af90b25..cbcad7db 100755 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -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(); }} /> @@ -1039,6 +1066,7 @@ export default function InsuranceStatusPage() { setPreviewFallbackFilename(fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`); setPreviewOpen(true); } + processNextInQueue(); }} /> @@ -1057,6 +1085,7 @@ export default function InsuranceStatusPage() { fallbackFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`, ); setPreviewOpen(true); + processNextInQueue(); }} /> @@ -1081,6 +1110,7 @@ export default function InsuranceStatusPage() { setPreviewFallbackFilename(fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`); setPreviewOpen(true); } + processNextInQueue(); }} /> @@ -1102,6 +1132,7 @@ export default function InsuranceStatusPage() { setPreviewFallbackFilename(fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`); setPreviewOpen(true); } + processNextInQueue(); }} /> @@ -1123,6 +1154,7 @@ export default function InsuranceStatusPage() { setPreviewFallbackFilename(fallbackFilename ?? `eligibility_cca_${memberId}.pdf`); setPreviewOpen(true); } + processNextInQueue(); }} /> diff --git a/packages/db/types/patient-types.ts b/packages/db/types/patient-types.ts index 609f5d8c..7686dc8a 100755 --- a/packages/db/types/patient-types.ts +++ b/packages/db/types/patient-types.ts @@ -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, });