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";
|
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.
|
// siteKey → autoCheck value used by the insurance-status page prefill.
|
||||||
// For MH, pass the patient's DOB so under-21 patients route to CMSP.
|
// For MH, pass the patient's DOB so under-21 patients route to CMSP.
|
||||||
export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null): string {
|
export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null): string {
|
||||||
@@ -109,6 +116,32 @@ interface StorageLike {
|
|||||||
|
|
||||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
// ─── 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 {
|
function patientToResult(p: any): ResolvedPatient {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
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)
|
// 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 existingPatient = await findPatientByMemberId(memberId, dob, storage);
|
||||||
const patient: ResolvedPatient | null = existingPatient
|
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
|
// Determine siteKey
|
||||||
const siteKey = resolveSiteKey(
|
const siteKey = resolveSiteKey(
|
||||||
patient?.insuranceProvider ?? null,
|
patient?.insuranceProvider ?? null,
|
||||||
@@ -348,7 +406,7 @@ async function handleEligibilityById(
|
|||||||
memberId,
|
memberId,
|
||||||
dob: resolvedDob,
|
dob: resolvedDob,
|
||||||
patient,
|
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,
|
storage: StorageLike,
|
||||||
customAliases: { phrase: string; cdtCode: string }[] = []
|
customAliases: { phrase: string; cdtCode: string }[] = []
|
||||||
): Promise<ChatResponse> {
|
): 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
|
// 1. Resolve patient
|
||||||
let patient: ResolvedPatient | null = null;
|
let patient: ResolvedPatient | null = null;
|
||||||
let memberId = c.memberId?.trim() ?? 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
|
// 2. Determine siteKey
|
||||||
const siteKey = resolveSiteKey(
|
const siteKey = resolveSiteKey(
|
||||||
patient?.insuranceProvider ?? null,
|
patient?.insuranceProvider ?? null,
|
||||||
@@ -426,7 +509,7 @@ async function handleCheckAndClaim(
|
|||||||
dob,
|
dob,
|
||||||
patient,
|
patient,
|
||||||
procedureNames: c.procedureNames ?? [],
|
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()
|
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||||||
: `Member ID ${memberId}`;
|
: `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: ${
|
const reply = `Ready to check eligibility for ${label} and claim: ${
|
||||||
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
|
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
|
// Resolve service date: use explicit date from message, then latest appointment, then ask
|
||||||
let serviceDate: string | null = c.appointmentDate ?? null;
|
let serviceDate: string | null = c.appointmentDate ?? null;
|
||||||
let appointmentId: number | null = null;
|
let appointmentId: number | null = null;
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
{ "keyword": "united healthone sco", "siteKey": "UNITED_SCO" },
|
{ "keyword": "united healthone sco", "siteKey": "UNITED_SCO" },
|
||||||
{ "keyword": "united healthcare sco", "siteKey": "UNITED_SCO" },
|
{ "keyword": "united healthcare sco", "siteKey": "UNITED_SCO" },
|
||||||
{ "keyword": "united healthcare", "siteKey": "UNITED_SCO" },
|
{ "keyword": "united healthcare", "siteKey": "UNITED_SCO" },
|
||||||
|
{ "keyword": "united sco / dental hub", "siteKey": "UNITED_SCO" },
|
||||||
{ "keyword": "united sco", "siteKey": "UNITED_SCO" },
|
{ "keyword": "united sco", "siteKey": "UNITED_SCO" },
|
||||||
{ "keyword": "dentalhub", "siteKey": "UNITED_SCO" },
|
{ "keyword": "dentalhub", "siteKey": "UNITED_SCO" },
|
||||||
|
{ "keyword": "dental hub", "siteKey": "UNITED_SCO" },
|
||||||
{ "keyword": "united_sco", "siteKey": "UNITED_SCO" },
|
{ "keyword": "united_sco", "siteKey": "UNITED_SCO" },
|
||||||
|
|
||||||
{ "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" },
|
{ "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" },
|
||||||
|
|||||||
@@ -813,6 +813,18 @@ export default function ClaimsPage() {
|
|||||||
setIsClaimFormOpen(false);
|
setIsClaimFormOpen(false);
|
||||||
|
|
||||||
clearUrlParams(["newPatient", "appointmentId"]);
|
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
|
// 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 pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "united-sco" | "tufts-sco" | "cca" | null>(null);
|
||||||
const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
|
const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
|
||||||
const pendingScrollTo = useRef<string | null>(null);
|
const pendingScrollTo = useRef<string | null>(null);
|
||||||
|
const [prefillTick, setPrefillTick] = useState(0);
|
||||||
|
|
||||||
// Prefill from chatbot
|
// Prefill from chatbot
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -188,6 +189,9 @@ export default function InsuranceStatusPage() {
|
|||||||
}
|
}
|
||||||
if (ac) pendingAutoCheck.current = ac;
|
if (ac) pendingAutoCheck.current = ac;
|
||||||
sessionStorage.removeItem("chatbot_eligibility");
|
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 {}
|
} catch {}
|
||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
@@ -658,7 +662,7 @@ export default function InsuranceStatusPage() {
|
|||||||
setTriggerTarget(check);
|
setTriggerTarget(check);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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)
|
// small helper: remove given query params from the current URL (silent, no reload)
|
||||||
const clearUrlParams = (params: string[]) => {
|
const clearUrlParams = (params: string[]) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user