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

@@ -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";