feat: AI claim queue auto-return + D1120 age warning
- claims-page: after auto-submit closes the form, automatically navigate back to /appointments when an AI claim queue is pending resume, eliminating the manual navigation delay between appointments - internal-chat-workflow: warn and block claim when D1120 (child prophy) is requested for a patient aged 14+ — recommend D1110 (adult prophy) instead, and advise manual claim if user insists on D1120 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,13 @@ export function deriveSiteKey(provider: string): string {
|
||||
return "MH";
|
||||
}
|
||||
|
||||
// Returns true when the user said "united" without a specific qualifier
|
||||
// (e.g. "united sco", "united healthcare") — needs clarification.
|
||||
function isAmbiguousUnited(hint: string | null | undefined): boolean {
|
||||
const h = (hint ?? "").toLowerCase().trim();
|
||||
return h === "united";
|
||||
}
|
||||
|
||||
// siteKey → autoCheck value used by the insurance-status page prefill.
|
||||
// For MH, pass the patient's DOB so under-21 patients route to CMSP.
|
||||
export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null): string {
|
||||
@@ -109,6 +116,32 @@ interface StorageLike {
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function calcAge(dob: string | null | undefined, on?: string | null): number | null {
|
||||
if (!dob) return null;
|
||||
try {
|
||||
const birth = new Date(dob);
|
||||
const ref = on ? new Date(on) : new Date();
|
||||
let age = ref.getFullYear() - birth.getFullYear();
|
||||
const m = ref.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && ref.getDate() < birth.getDate())) age--;
|
||||
return age;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function checkD1120Age(
|
||||
matched: CdtResult[],
|
||||
patientName: string,
|
||||
dob: string | null | undefined,
|
||||
serviceDate?: string | null
|
||||
): ChatResponse | null {
|
||||
if (!matched.some((r) => r.code === "D1120")) return null;
|
||||
const age = calcAge(dob, serviceDate);
|
||||
if (age === null || age < 14) return null;
|
||||
return {
|
||||
reply: `${patientName} is ${age} years old. D1120 (child prophy) is only for patients under 14 — I recommend using D1110 (adult prophy) instead. If you still need D1120, please claim manually for this patient.`,
|
||||
};
|
||||
}
|
||||
|
||||
function patientToResult(p: any): ResolvedPatient {
|
||||
return {
|
||||
id: p.id,
|
||||
@@ -317,6 +350,13 @@ async function handleEligibilityById(
|
||||
};
|
||||
}
|
||||
|
||||
// User explicitly chose "Other United" — it's not in the app
|
||||
if ((c.insuranceHint ?? "").toLowerCase().includes("other united")) {
|
||||
return {
|
||||
reply: "That United plan is not currently supported in the app. Please check it manually.",
|
||||
};
|
||||
}
|
||||
|
||||
// Try to resolve existing patient for name display + insurance (use DOB to pick the right family member)
|
||||
const existingPatient = await findPatientByMemberId(memberId, dob, storage);
|
||||
const patient: ResolvedPatient | null = existingPatient
|
||||
@@ -331,6 +371,24 @@ async function handleEligibilityById(
|
||||
};
|
||||
}
|
||||
|
||||
// "united" alone is ambiguous — there's United SCO (Dental Hub) in the app
|
||||
// and other United plans that are not supported.
|
||||
if (isAmbiguousUnited(c.insuranceHint) && !patient?.insuranceProvider) {
|
||||
const label = patient
|
||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||||
: `Member ID ${memberId}`;
|
||||
return {
|
||||
reply: `Which United plan is ${label}?`,
|
||||
action: "need_insurance_clarification",
|
||||
actionData: {
|
||||
memberId,
|
||||
dob: resolvedDob,
|
||||
patient,
|
||||
options: ["United SCO / Dental Hub", "Other United (not in app)"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Determine siteKey
|
||||
const siteKey = resolveSiteKey(
|
||||
patient?.insuranceProvider ?? null,
|
||||
@@ -348,7 +406,7 @@ async function handleEligibilityById(
|
||||
memberId,
|
||||
dob: resolvedDob,
|
||||
patient,
|
||||
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"],
|
||||
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United SCO / Dental Hub"],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -378,6 +436,13 @@ async function handleCheckAndClaim(
|
||||
storage: StorageLike,
|
||||
customAliases: { phrase: string; cdtCode: string }[] = []
|
||||
): Promise<ChatResponse> {
|
||||
// User explicitly chose "Other United" — it's not in the app
|
||||
if ((c.insuranceHint ?? "").toLowerCase().includes("other united")) {
|
||||
return {
|
||||
reply: "That United plan is not currently supported in the app. Please check it manually.",
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Resolve patient
|
||||
let patient: ResolvedPatient | null = null;
|
||||
let memberId = c.memberId?.trim() ?? null;
|
||||
@@ -408,6 +473,24 @@ async function handleCheckAndClaim(
|
||||
};
|
||||
}
|
||||
|
||||
// "united" alone is ambiguous — clarify before proceeding
|
||||
if (isAmbiguousUnited(c.insuranceHint) && !patient?.insuranceProvider) {
|
||||
const label = patient
|
||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||||
: `Member ID ${memberId}`;
|
||||
return {
|
||||
reply: `Which United plan is ${label}?`,
|
||||
action: "need_insurance_clarification",
|
||||
actionData: {
|
||||
memberId,
|
||||
dob: resolvedDob,
|
||||
patient,
|
||||
procedureNames: c.procedureNames ?? [],
|
||||
options: ["United SCO / Dental Hub", "Other United (not in app)"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Determine siteKey
|
||||
const siteKey = resolveSiteKey(
|
||||
patient?.insuranceProvider ?? null,
|
||||
@@ -426,7 +509,7 @@ async function handleCheckAndClaim(
|
||||
dob,
|
||||
patient,
|
||||
procedureNames: c.procedureNames ?? [],
|
||||
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"],
|
||||
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United SCO / Dental Hub"],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -454,6 +537,10 @@ async function handleCheckAndClaim(
|
||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||||
: `Member ID ${memberId}`;
|
||||
|
||||
// D1120 age check — recommend D1110 for patients 14+
|
||||
const d1120Warning = checkD1120Age(matched, label, patient?.dateOfBirth ?? resolvedDob, c.appointmentDate);
|
||||
if (d1120Warning) return d1120Warning;
|
||||
|
||||
const reply = `Ready to check eligibility for ${label} and claim: ${
|
||||
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
|
||||
}.`;
|
||||
@@ -521,6 +608,10 @@ async function handleClaimOnly(
|
||||
};
|
||||
}
|
||||
|
||||
// D1120 age check — recommend D1110 for patients 14+
|
||||
const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate);
|
||||
if (d1120Warning) return d1120Warning;
|
||||
|
||||
// Resolve service date: use explicit date from message, then latest appointment, then ask
|
||||
let serviceDate: string | null = c.appointmentDate ?? null;
|
||||
let appointmentId: number | null = null;
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
{ "keyword": "united healthone sco", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "united healthcare sco", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "united healthcare", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "united sco / dental hub", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "united sco", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "dentalhub", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "dental hub", "siteKey": "UNITED_SCO" },
|
||||
{ "keyword": "united_sco", "siteKey": "UNITED_SCO" },
|
||||
|
||||
{ "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" },
|
||||
|
||||
@@ -813,6 +813,18 @@ export default function ClaimsPage() {
|
||||
setIsClaimFormOpen(false);
|
||||
|
||||
clearUrlParams(["newPatient", "appointmentId"]);
|
||||
|
||||
// If an AI claim queue is pending resume, go straight back to appointments
|
||||
try {
|
||||
const raw = sessionStorage.getItem("ai_claim_queue");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.pendingResume && Array.isArray(parsed.appointments) && parsed.appointments.length > 0) {
|
||||
setWouterLocation("/appointments");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Pre Auth section
|
||||
|
||||
@@ -170,6 +170,7 @@ export default function InsuranceStatusPage() {
|
||||
const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "united-sco" | "tufts-sco" | "cca" | null>(null);
|
||||
const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
|
||||
const pendingScrollTo = useRef<string | null>(null);
|
||||
const [prefillTick, setPrefillTick] = useState(0);
|
||||
|
||||
// Prefill from chatbot
|
||||
useEffect(() => {
|
||||
@@ -188,6 +189,9 @@ export default function InsuranceStatusPage() {
|
||||
}
|
||||
if (ac) pendingAutoCheck.current = ac;
|
||||
sessionStorage.removeItem("chatbot_eligibility");
|
||||
// Increment tick so the auto-trigger effect re-fires even when
|
||||
// memberId/dob are unchanged (same patient submitted a second time).
|
||||
setPrefillTick((n) => n + 1);
|
||||
} catch {}
|
||||
};
|
||||
apply();
|
||||
@@ -658,7 +662,7 @@ export default function InsuranceStatusPage() {
|
||||
setTriggerTarget(check);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [memberId, dateOfBirth]);
|
||||
}, [memberId, dateOfBirth, prefillTick]);
|
||||
|
||||
// small helper: remove given query params from the current URL (silent, no reload)
|
||||
const clearUrlParams = (params: string[]) => {
|
||||
|
||||
Reference in New Issue
Block a user