feat: AI chat scheduling, claim automation, and session improvements
- Internal AI chat: schedule_appointment intent books earliest available slot in Column A using office hours; claim_only intent looks up latest past appointment for service date, asks user when two appointments are within 7 days, auto-triggers correct Selenium worker with mapped prices - Gemini model updated to gemini-flash-latest; conversation history (15 messages) passed for pronoun/reference resolution; history trimmed to start with user turn so Gemini doesn't reject the context - Insurance alias file (insuranceAliases.json) replaces hardcoded siteKey matching; "tufs" now resolves to TUFTS_SCO - DOB format normalized (MM/DD/YYYY → YYYY-MM-DD) before parseLocalDate; autoCheck now fires for all insurance types, not just MH/CMSP - Claim form auto-submit: all handlers (MH, CCA, DDMA, UnitedDH, Tufts) accept formToUse and receive fee-schedule-priced form; prefillDone set after chatbot code prefill so autoSubmit gate opens correctly - Chatbot: chat history persisted in sessionStorage, cleared on logout and auto-logout; Clear button writes fresh state synchronously; message history window increased to 15 - DentaQuest/TuftsSCO Selenium: "Remember me" checkbox clicked before sign-in to persist OTP trust cookie across sessions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ export type InternalChatIntent =
|
|||||||
| "eligibility_by_id" // by explicit memberId + dob (no name)
|
| "eligibility_by_id" // by explicit memberId + dob (no name)
|
||||||
| "check_and_claim" // eligibility + claim procedures
|
| "check_and_claim" // eligibility + claim procedures
|
||||||
| "find_patient" // look up patient record only
|
| "find_patient" // look up patient record only
|
||||||
|
| "schedule_appointment" // add patient to today's (or specified) schedule
|
||||||
|
| "claim_only" // submit claim for procedures (no eligibility check)
|
||||||
| "navigate_claims"
|
| "navigate_claims"
|
||||||
| "navigate_schedule"
|
| "navigate_schedule"
|
||||||
| "general";
|
| "general";
|
||||||
@@ -14,13 +16,16 @@ export type InternalChatIntent =
|
|||||||
export interface ChatClassification {
|
export interface ChatClassification {
|
||||||
intent: InternalChatIntent;
|
intent: InternalChatIntent;
|
||||||
// --- patient resolution (one of name OR id+dob) ---
|
// --- patient resolution (one of name OR id+dob) ---
|
||||||
patientName?: string; // for check_eligibility / find_patient
|
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
|
||||||
memberId?: string; // for eligibility_by_id / check_and_claim
|
memberId?: string; // for eligibility_by_id / check_and_claim
|
||||||
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
|
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
|
||||||
// --- insurance hint (only if explicitly stated in the message) ---
|
// --- insurance hint (only if explicitly stated in the message) ---
|
||||||
insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA"
|
insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA"
|
||||||
// --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) ---
|
// --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) ---
|
||||||
procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"]
|
procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"]
|
||||||
|
// --- scheduling ---
|
||||||
|
appointmentDate?: string; // for schedule_appointment, YYYY-MM-DD (omit = today)
|
||||||
|
appointmentTime?: string; // for schedule_appointment, HH:MM 24h (omit = 09:00)
|
||||||
fallbackReply: string;
|
fallbackReply: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +43,8 @@ Respond ONLY with valid JSON (no markdown fences):
|
|||||||
"dob": "<date of birth in MM/DD/YYYY if given>",
|
"dob": "<date of birth in MM/DD/YYYY if given>",
|
||||||
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
|
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
|
||||||
"procedureNames": ["<raw procedure name>", ...],
|
"procedureNames": ["<raw procedure name>", ...],
|
||||||
|
"appointmentDate": "<YYYY-MM-DD if a specific date is mentioned, omit for today>",
|
||||||
|
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
|
||||||
"fallbackReply": "<1-2 sentence reply to show the user>"
|
"fallbackReply": "<1-2 sentence reply to show the user>"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,28 +60,44 @@ Intents:
|
|||||||
e.g. "check Maria Jesus and claim D0120 D1110"
|
e.g. "check Maria Jesus and claim D0120 D1110"
|
||||||
- find_patient : look up a patient record only, no eligibility
|
- find_patient : look up a patient record only, no eligibility
|
||||||
e.g. "find patient John", "look up Smith"
|
e.g. "find patient John", "look up Smith"
|
||||||
|
- schedule_appointment : add a patient to the schedule (today or a specified date/time)
|
||||||
|
e.g. "put John Smith in today's schedule"
|
||||||
|
e.g. "schedule Maria at 2pm tomorrow"
|
||||||
|
e.g. "add Jane Doe at 10:30"
|
||||||
|
- claim_only : submit a claim for procedures WITHOUT an eligibility check
|
||||||
|
e.g. "claim comprehensive exam and Pano for her"
|
||||||
|
e.g. "claim D0120 and D1110 for John Smith"
|
||||||
|
e.g. "bill adult cleaning for Maria"
|
||||||
|
Use this when no eligibility check is requested — just billing/claiming services
|
||||||
- navigate_claims : open the claims page
|
- navigate_claims : open the claims page
|
||||||
- navigate_schedule : open the appointments/schedule page
|
- navigate_schedule : open the appointments/schedule page
|
||||||
- general : anything else
|
- general : anything else
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- For check_and_claim, procedureNames should be the RAW user text
|
- For check_and_claim and claim_only, procedureNames should be the RAW user text
|
||||||
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
|
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
|
||||||
- insuranceHint is only set when the user explicitly names an insurance in the message
|
- insuranceHint is only set when the user explicitly names an insurance in the message
|
||||||
- Keep fallbackReply to 1-2 sentences
|
- Keep fallbackReply to 1-2 sentences
|
||||||
- For navigate intents, fallbackReply = "Opening the [page] page..."`;
|
- For navigate intents, fallbackReply = "Opening the [page] page..."
|
||||||
|
- For schedule_appointment, appointmentDate omitted means today; appointmentTime omitted means no preference
|
||||||
|
- IMPORTANT: Use the conversation history to resolve pronouns and references.
|
||||||
|
If the user says "her", "him", "them", "the patient", or "same patient", look back through
|
||||||
|
the conversation history to find the patient name that was mentioned most recently.
|
||||||
|
Always populate patientName (or memberId) from history when a pronoun is used.
|
||||||
|
Never return an empty patientName just because the current message uses a pronoun.`;
|
||||||
|
|
||||||
// ─── Classifier ───────────────────────────────────────────────────────────────
|
// ─── Classifier ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function classifyInternalChat(
|
export async function classifyInternalChat(
|
||||||
message: string,
|
message: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
extraSystemPrompt?: string
|
extraSystemPrompt?: string,
|
||||||
|
history: { role: "user" | "assistant"; text: string }[] = []
|
||||||
): Promise<ChatClassification> {
|
): Promise<ChatClassification> {
|
||||||
const fallback: ChatClassification = {
|
const fallback: ChatClassification = {
|
||||||
intent: "general",
|
intent: "general",
|
||||||
fallbackReply:
|
fallbackReply:
|
||||||
"I can search for a patient, check eligibility, run check & claim, or navigate to claims or appointments.",
|
"I can search for a patient, check eligibility, run check & claim, schedule appointments, or navigate to claims or appointments.",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!apiKey) return fallback;
|
if (!apiKey) return fallback;
|
||||||
@@ -84,9 +107,24 @@ export async function classifyInternalChat(
|
|||||||
: BASE_SYSTEM_PROMPT;
|
: BASE_SYSTEM_PROMPT;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
const llm = new ChatGoogleGenerativeAI({ model: "gemini-flash-latest", apiKey });
|
||||||
|
|
||||||
|
// Gemini requires conversation to start with a user turn — drop any leading assistant messages
|
||||||
|
const trimmedHistory = history.slice(
|
||||||
|
history.findIndex((h) => h.role === "user")
|
||||||
|
).filter((_, i, arr) => {
|
||||||
|
// Also drop consecutive same-role messages (keep last of each run)
|
||||||
|
if (i === arr.length - 1) return true;
|
||||||
|
return arr[i]!.role !== arr[i + 1]!.role;
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyMessages = trimmedHistory.map((h) => ({
|
||||||
|
role: h.role,
|
||||||
|
content: h.text,
|
||||||
|
}));
|
||||||
const response = await llm.invoke([
|
const response = await llm.invoke([
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
|
...historyMessages,
|
||||||
{ role: "user", content: message },
|
{ role: "user", content: message },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ChatClassification } from "./internal-chat-graph";
|
import { ChatClassification } from "./internal-chat-graph";
|
||||||
import { lookupCdtCodes } from "./cdt-lookup";
|
import { lookupCdtCodes } from "./cdt-lookup";
|
||||||
|
import insuranceAliases from "../data/insuranceAliases.json";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -35,7 +36,10 @@ export interface ChatResponse {
|
|||||||
| "check_eligibility_prefill"
|
| "check_eligibility_prefill"
|
||||||
| "eligibility_id_ready"
|
| "eligibility_id_ready"
|
||||||
| "check_and_claim_ready"
|
| "check_and_claim_ready"
|
||||||
| "need_insurance_clarification";
|
| "need_insurance_clarification"
|
||||||
|
| "appointment_created"
|
||||||
|
| "claim_only_ready"
|
||||||
|
| "need_appointment_selection";
|
||||||
actionData?: Record<string, any>;
|
actionData?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +47,10 @@ export interface ChatResponse {
|
|||||||
|
|
||||||
export function deriveSiteKey(provider: string): string {
|
export function deriveSiteKey(provider: string): string {
|
||||||
const p = (provider ?? "").toLowerCase().trim();
|
const p = (provider ?? "").toLowerCase().trim();
|
||||||
if (!p || p.includes("masshealth") || p === "mh" || p === "mass health") return "MH";
|
if (!p) return "MH";
|
||||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
for (const { keyword, siteKey } of insuranceAliases) {
|
||||||
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
|
if (p.includes(keyword.toLowerCase())) return siteKey;
|
||||||
if (p.includes("delta dental ins") || p.includes("delta ins")) return "DELTA_INS";
|
}
|
||||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TUFTS_SCO";
|
|
||||||
if ((p.includes("united") && p.includes("sco")) || p.includes("dentalhub") || p === "united_sco") return "UNITED_SCO";
|
|
||||||
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS_MA";
|
|
||||||
return "MH";
|
return "MH";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,10 @@ interface StorageLike {
|
|||||||
offset: number;
|
offset: number;
|
||||||
}): Promise<any[] | null>;
|
}): Promise<any[] | null>;
|
||||||
getPatientByInsuranceId(id: string): Promise<any | null>;
|
getPatientByInsuranceId(id: string): Promise<any | null>;
|
||||||
|
createAppointment(appointment: any): Promise<any>;
|
||||||
|
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
|
||||||
|
getOfficeHours(userId: number): Promise<any | null>;
|
||||||
|
getAppointmentsByPatientId(patientId: number): Promise<any[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||||
@@ -210,6 +215,18 @@ export async function runInternalChatWorkflow(
|
|||||||
return await handleCheckAndClaim(classification, storage, customAliases);
|
return await handleCheckAndClaim(classification, storage, customAliases);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Claim only (no eligibility check) ─────────────────────────────────────
|
||||||
|
|
||||||
|
if (intent === "claim_only") {
|
||||||
|
return await handleClaimOnly(classification, storage, customAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schedule appointment ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (intent === "schedule_appointment") {
|
||||||
|
return await handleScheduleAppointment(classification, _userId, storage);
|
||||||
|
}
|
||||||
|
|
||||||
// ── General ────────────────────────────────────────────────────────────────
|
// ── General ────────────────────────────────────────────────────────────────
|
||||||
return { reply: classification.fallbackReply };
|
return { reply: classification.fallbackReply };
|
||||||
}
|
}
|
||||||
@@ -223,9 +240,9 @@ async function handleEligibilityById(
|
|||||||
const memberId = c.memberId?.trim();
|
const memberId = c.memberId?.trim();
|
||||||
const dob = c.dob?.trim();
|
const dob = c.dob?.trim();
|
||||||
|
|
||||||
if (!memberId || !dob) {
|
if (!memberId) {
|
||||||
return {
|
return {
|
||||||
reply: "Please provide both a Member ID and Date of Birth (MM/DD/YYYY).",
|
reply: "Please provide a Member ID to run the eligibility check.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +252,14 @@ async function handleEligibilityById(
|
|||||||
? patientToResult(existingPatient)
|
? patientToResult(existingPatient)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Use stored DOB if not provided in the message
|
||||||
|
const resolvedDob = dob ?? patient?.dateOfBirth ?? null;
|
||||||
|
if (!resolvedDob) {
|
||||||
|
return {
|
||||||
|
reply: "Please provide a Date of Birth (MM/DD/YYYY) to run the eligibility check.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Determine siteKey
|
// Determine siteKey
|
||||||
const siteKey = resolveSiteKey(
|
const siteKey = resolveSiteKey(
|
||||||
patient?.insuranceProvider ?? null,
|
patient?.insuranceProvider ?? null,
|
||||||
@@ -250,7 +275,7 @@ async function handleEligibilityById(
|
|||||||
action: "need_insurance_clarification",
|
action: "need_insurance_clarification",
|
||||||
actionData: {
|
actionData: {
|
||||||
memberId,
|
memberId,
|
||||||
dob,
|
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/DentalHub"],
|
||||||
},
|
},
|
||||||
@@ -267,7 +292,7 @@ async function handleEligibilityById(
|
|||||||
actionData: {
|
actionData: {
|
||||||
patient,
|
patient,
|
||||||
memberId,
|
memberId,
|
||||||
dob,
|
dob: resolvedDob,
|
||||||
siteKey,
|
siteKey,
|
||||||
autoCheck: siteKeyToAutoCheck(siteKey),
|
autoCheck: siteKeyToAutoCheck(siteKey),
|
||||||
},
|
},
|
||||||
@@ -302,9 +327,12 @@ async function handleCheckAndClaim(
|
|||||||
reply: "Please include either a Member ID or a patient name so I can look up their record.",
|
reply: "Please include either a Member ID or a patient name so I can look up their record.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!dob) {
|
|
||||||
|
// Use stored DOB if not provided in the message
|
||||||
|
const resolvedDob = dob ?? patient?.dateOfBirth ?? null;
|
||||||
|
if (!resolvedDob) {
|
||||||
return {
|
return {
|
||||||
reply: `I have the Member ID (${memberId}) but need a Date of Birth (MM/DD/YYYY) to run the eligibility check.`,
|
reply: `I have the Member ID (${memberId}) but couldn't find a Date of Birth on file. Please provide it (MM/DD/YYYY).`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +386,7 @@ async function handleCheckAndClaim(
|
|||||||
actionData: {
|
actionData: {
|
||||||
patient,
|
patient,
|
||||||
memberId,
|
memberId,
|
||||||
dob,
|
dob: resolvedDob,
|
||||||
siteKey,
|
siteKey,
|
||||||
autoCheck: siteKeyToAutoCheck(siteKey),
|
autoCheck: siteKeyToAutoCheck(siteKey),
|
||||||
cdtResults,
|
cdtResults,
|
||||||
@@ -367,6 +395,269 @@ async function handleCheckAndClaim(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── claim_only ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleClaimOnly(
|
||||||
|
c: ChatClassification,
|
||||||
|
storage: StorageLike,
|
||||||
|
customAliases: { phrase: string; cdtCode: string }[]
|
||||||
|
): Promise<ChatResponse> {
|
||||||
|
// Resolve patient
|
||||||
|
let patient: ResolvedPatient | null = null;
|
||||||
|
|
||||||
|
if (c.memberId?.trim()) {
|
||||||
|
const existing = await storage.getPatientByInsuranceId(c.memberId.trim());
|
||||||
|
if (existing) patient = patientToResult(existing);
|
||||||
|
} else if (c.patientName?.trim()) {
|
||||||
|
const raw = await findPatientByName(c.patientName.trim(), storage);
|
||||||
|
if (raw) patient = patientToResult(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!patient) {
|
||||||
|
return {
|
||||||
|
reply: "Please include a patient name or Member ID so I can look them up.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||||||
|
|
||||||
|
// Map procedure names → CDT codes
|
||||||
|
const procedureNames = c.procedureNames ?? [];
|
||||||
|
if (procedureNames.length === 0) {
|
||||||
|
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdtResults = lookupCdtCodes(procedureNames, customAliases);
|
||||||
|
const matched = cdtResults.filter((r) => r.code !== null);
|
||||||
|
const unmatched = cdtResults.filter((r) => r.code === null);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (!serviceDate) {
|
||||||
|
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 (diffDays < 7) {
|
||||||
|
// Use UTC methods to avoid local-timezone day-shift on midnight UTC dates
|
||||||
|
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")}`;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
reply: `Found two appointments close together for ${fullName}: ${fmtUTC(sorted[0])} and ${fmtUTC(sorted[1])}. Which date should I use for the claim?`,
|
||||||
|
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 })),
|
||||||
|
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]) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 — ask for the service date
|
||||||
|
const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures";
|
||||||
|
return {
|
||||||
|
reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
|
||||||
|
|
||||||
|
const [sy, sm, sd] = serviceDate.split("-");
|
||||||
|
const dateLabel = `${sm}/${sd}/${sy}`;
|
||||||
|
|
||||||
|
let reply = `Opening claim for ${fullName} (service date ${dateLabel}): ${
|
||||||
|
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures matched"
|
||||||
|
}.`;
|
||||||
|
if (unmatched.length > 0) {
|
||||||
|
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — verify manually.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reply,
|
||||||
|
action: "claim_only_ready",
|
||||||
|
actionData: {
|
||||||
|
patient,
|
||||||
|
siteKey,
|
||||||
|
serviceDate,
|
||||||
|
appointmentId,
|
||||||
|
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── schedule_appointment ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_STAFF_ID = 1; // Column A
|
||||||
|
const SLOT_DURATION = 30; // minutes
|
||||||
|
|
||||||
|
/** Convert "HH:MM" to total minutes since midnight */
|
||||||
|
function toMinutes(t: string): number {
|
||||||
|
const [h, m] = t.split(":").map(Number);
|
||||||
|
return (h ?? 0) * 60 + (m ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert total minutes since midnight to "HH:MM" */
|
||||||
|
function fromMinutes(m: number): string {
|
||||||
|
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the list of valid start minutes for a day using office hours.
|
||||||
|
* Skips the lunch gap (amEnd → pmStart).
|
||||||
|
*/
|
||||||
|
function buildSlots(dayHours: { amStart: string; amEnd: string; pmStart: string; pmEnd: string }): number[] {
|
||||||
|
const slots: number[] = [];
|
||||||
|
const ranges = [
|
||||||
|
[toMinutes(dayHours.amStart), toMinutes(dayHours.amEnd)],
|
||||||
|
[toMinutes(dayHours.pmStart), toMinutes(dayHours.pmEnd)],
|
||||||
|
];
|
||||||
|
for (const [start, end] of ranges) {
|
||||||
|
for (let t = start!; t + SLOT_DURATION <= end!; t += SLOT_DURATION) {
|
||||||
|
slots.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScheduleAppointment(
|
||||||
|
c: ChatClassification,
|
||||||
|
userId: number,
|
||||||
|
storage: StorageLike
|
||||||
|
): Promise<ChatResponse> {
|
||||||
|
const name = c.patientName?.trim();
|
||||||
|
if (!name) {
|
||||||
|
return { reply: "Please include the patient's name so I can find them." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await findPatientByName(name, storage);
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
reply: `No patient found matching "${name}". Please check the spelling or add them on the Patients page first.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const patient = patientToResult(raw);
|
||||||
|
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||||||
|
|
||||||
|
// Resolve date
|
||||||
|
const today = new Date();
|
||||||
|
const localDate = c.appointmentDate
|
||||||
|
? new Date(c.appointmentDate + "T00:00:00")
|
||||||
|
: new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||||
|
const dateStr = `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, "0")}-${String(localDate.getDate()).padStart(2, "0")}`;
|
||||||
|
const dateLabel = localDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
|
||||||
|
// If time was explicitly provided, use it directly
|
||||||
|
if (c.appointmentTime) {
|
||||||
|
const startTime = c.appointmentTime;
|
||||||
|
const startMin = toMinutes(startTime);
|
||||||
|
const endTime = fromMinutes(startMin + SLOT_DURATION);
|
||||||
|
await storage.createAppointment({
|
||||||
|
patientId: patient.id,
|
||||||
|
userId,
|
||||||
|
staffId: DEFAULT_STAFF_ID,
|
||||||
|
title: dateLabel,
|
||||||
|
date: localDate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
type: "recall",
|
||||||
|
status: "scheduled",
|
||||||
|
movedByAi: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A).`,
|
||||||
|
action: "appointment_created",
|
||||||
|
actionData: { patient, date: dateStr, startTime, endTime },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No time specified — find earliest available slot in Column A
|
||||||
|
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
||||||
|
const dayName = dayNames[localDate.getDay()]!;
|
||||||
|
|
||||||
|
// Load office hours (fall back to 9–12 / 13–17 if not configured)
|
||||||
|
const officeHours = await storage.getOfficeHours(userId);
|
||||||
|
const dayHours = officeHours?.data?.doctors?.[dayName] ?? {
|
||||||
|
amStart: "09:00", amEnd: "12:00", pmStart: "13:00", pmEnd: "17:00", enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!dayHours.enabled) {
|
||||||
|
return { reply: `The office is closed on ${dayName}. Please choose a different day.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSlots = buildSlots(dayHours);
|
||||||
|
|
||||||
|
// Fetch all Column A appointments for that day
|
||||||
|
const existing = (await storage.getAppointmentsByDateForUser(dateStr, userId))
|
||||||
|
.filter((a: any) => a.staffId === DEFAULT_STAFF_ID);
|
||||||
|
|
||||||
|
// Find first slot that doesn't overlap any existing appointment
|
||||||
|
const booked = existing.map((a: any) => ({
|
||||||
|
start: toMinutes(a.startTime),
|
||||||
|
end: toMinutes(a.endTime),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const availableStart = allSlots.find((slotStart) => {
|
||||||
|
const slotEnd = slotStart + SLOT_DURATION;
|
||||||
|
return !booked.some((b) => slotStart < b.end && slotEnd > b.start);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (availableStart === undefined) {
|
||||||
|
return {
|
||||||
|
reply: `Column A is fully booked on ${dateLabel}. Please pick a different date or time.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = fromMinutes(availableStart);
|
||||||
|
const endTime = fromMinutes(availableStart + SLOT_DURATION);
|
||||||
|
|
||||||
|
await storage.createAppointment({
|
||||||
|
patientId: patient.id,
|
||||||
|
userId,
|
||||||
|
staffId: DEFAULT_STAFF_ID,
|
||||||
|
title: dateLabel,
|
||||||
|
date: localDate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
type: "recall",
|
||||||
|
status: "scheduled",
|
||||||
|
movedByAi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A) — earliest available slot.`,
|
||||||
|
action: "appointment_created",
|
||||||
|
actionData: { patient, date: dateStr, startTime, endTime },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Insurance resolution helper ──────────────────────────────────────────────
|
// ─── Insurance resolution helper ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
32
apps/Backend/src/data/insuranceAliases.json
Normal file
32
apps/Backend/src/data/insuranceAliases.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{ "keyword": "masshealth", "siteKey": "MH" },
|
||||||
|
{ "keyword": "mass health", "siteKey": "MH" },
|
||||||
|
{ "keyword": "mh", "siteKey": "MH" },
|
||||||
|
|
||||||
|
{ "keyword": "commonwealth care alliance", "siteKey": "CCA" },
|
||||||
|
{ "keyword": "cca", "siteKey": "CCA" },
|
||||||
|
|
||||||
|
{ "keyword": "delta dental of massachusetts","siteKey": "DDMA" },
|
||||||
|
{ "keyword": "delta dental ma", "siteKey": "DDMA" },
|
||||||
|
{ "keyword": "ddma", "siteKey": "DDMA" },
|
||||||
|
|
||||||
|
{ "keyword": "delta dental ins", "siteKey": "DELTA_INS" },
|
||||||
|
{ "keyword": "delta ins", "siteKey": "DELTA_INS" },
|
||||||
|
{ "keyword": "delta dental", "siteKey": "DELTA_INS" },
|
||||||
|
|
||||||
|
{ "keyword": "tufts sco", "siteKey": "TUFTS_SCO" },
|
||||||
|
{ "keyword": "tufts", "siteKey": "TUFTS_SCO" },
|
||||||
|
{ "keyword": "tufs", "siteKey": "TUFTS_SCO" },
|
||||||
|
{ "keyword": "dentaquest", "siteKey": "TUFTS_SCO" },
|
||||||
|
{ "keyword": "tuftssco", "siteKey": "TUFTS_SCO" },
|
||||||
|
|
||||||
|
{ "keyword": "united healthone sco", "siteKey": "UNITED_SCO" },
|
||||||
|
{ "keyword": "united sco", "siteKey": "UNITED_SCO" },
|
||||||
|
{ "keyword": "dentalhub", "siteKey": "UNITED_SCO" },
|
||||||
|
{ "keyword": "united_sco", "siteKey": "UNITED_SCO" },
|
||||||
|
|
||||||
|
{ "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" },
|
||||||
|
{ "keyword": "bcbs ma", "siteKey": "BCBS_MA" },
|
||||||
|
{ "keyword": "blue cross", "siteKey": "BCBS_MA" },
|
||||||
|
{ "keyword": "bcbs", "siteKey": "BCBS_MA" }
|
||||||
|
]
|
||||||
@@ -165,7 +165,7 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
|||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
|
||||||
const { message } = req.body;
|
const { message, history } = req.body;
|
||||||
if (!message?.trim()) return res.status(400).json({ message: "message is required" });
|
if (!message?.trim()) return res.status(400).json({ message: "message is required" });
|
||||||
|
|
||||||
const aiSettings = await storage.getAiSettings(userId);
|
const aiSettings = await storage.getAiSettings(userId);
|
||||||
@@ -183,7 +183,8 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
|||||||
const classification = await classifyInternalChat(
|
const classification = await classifyInternalChat(
|
||||||
message.trim(),
|
message.trim(),
|
||||||
aiSettings.apiKey,
|
aiSettings.apiKey,
|
||||||
extraSystemPrompt || undefined
|
extraSystemPrompt || undefined,
|
||||||
|
Array.isArray(history) ? history : []
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ interface ClaimFormProps {
|
|||||||
patientId: number;
|
patientId: number;
|
||||||
appointmentId?: number;
|
appointmentId?: number;
|
||||||
autoSubmit?: boolean;
|
autoSubmit?: boolean;
|
||||||
|
/** When set, autoSubmit triggers this insurance's Selenium worker instead of MH */
|
||||||
|
autoSubmitSiteKey?: string;
|
||||||
/** When true: form saves to AppointmentProcedure (Select Procedures flow), shows only Save button */
|
/** When true: form saves to AppointmentProcedure (Select Procedures flow), shows only Save button */
|
||||||
proceduresOnly?: boolean;
|
proceduresOnly?: boolean;
|
||||||
onSubmit: (data: ClaimFormData) => Promise<Claim>;
|
onSubmit: (data: ClaimFormData) => Promise<Claim>;
|
||||||
@@ -101,6 +103,7 @@ export function ClaimForm({
|
|||||||
patientId,
|
patientId,
|
||||||
appointmentId,
|
appointmentId,
|
||||||
autoSubmit,
|
autoSubmit,
|
||||||
|
autoSubmitSiteKey,
|
||||||
proceduresOnly = false,
|
proceduresOnly = false,
|
||||||
onHandleAppointmentSubmit,
|
onHandleAppointmentSubmit,
|
||||||
onHandleUpdatePatient,
|
onHandleUpdatePatient,
|
||||||
@@ -487,6 +490,41 @@ export function ClaimForm({
|
|||||||
};
|
};
|
||||||
}, [appointmentId, serviceDate, existingClaimId]);
|
}, [appointmentId, serviceDate, existingClaimId]);
|
||||||
|
|
||||||
|
// Prefill service lines (and optional service date) from chatbot claim_only flow
|
||||||
|
useEffect(() => {
|
||||||
|
const raw = sessionStorage.getItem("chatbot_claim_prefill");
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const { codes, serviceDate } = JSON.parse(raw) as {
|
||||||
|
codes: { code: string; description: string }[];
|
||||||
|
serviceDate?: string;
|
||||||
|
};
|
||||||
|
sessionStorage.removeItem("chatbot_claim_prefill");
|
||||||
|
if (!codes?.length) return;
|
||||||
|
|
||||||
|
if (serviceDate) {
|
||||||
|
try {
|
||||||
|
const d = parseLocalDate(serviceDate);
|
||||||
|
setServiceDateValue(d);
|
||||||
|
setServiceDate(formatLocalDate(d));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm((prev) => {
|
||||||
|
const date = serviceDate ? serviceDate : prev.serviceDate;
|
||||||
|
const updatedLines = [...prev.serviceLines];
|
||||||
|
codes.forEach((c, i) => {
|
||||||
|
if (i < updatedLines.length) {
|
||||||
|
updatedLines[i] = { ...updatedLines[i]!, procedureCode: c.code, procedureDate: date };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { ...prev, serviceLines: updatedLines };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appointmentId) setPrefillDone(true);
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Restore NPI provider from saved procedures when npiProviders list loads after 2b
|
// Restore NPI provider from saved procedures when npiProviders list loads after 2b
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!savedProcNpiId || !npiProviders.length) return;
|
if (!savedProcNpiId || !npiProviders.length) return;
|
||||||
@@ -949,10 +987,11 @@ export function ClaimForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3rd Button workflow — CCA Claim: saves to DB then submits via Selenium
|
// 3rd Button workflow — CCA Claim: saves to DB then submits via Selenium
|
||||||
const handleCCAClaim = async () => {
|
const handleCCAClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
|
||||||
|
const f = formToUse ?? form;
|
||||||
const missingFields: string[] = [];
|
const missingFields: string[] = [];
|
||||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
if (!f.memberId?.trim()) missingFields.push("Member ID");
|
||||||
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
||||||
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -963,7 +1002,7 @@ export function ClaimForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredServiceLines = (form.serviceLines || []).filter(
|
const filteredServiceLines = (f.serviceLines || []).filter(
|
||||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||||
);
|
);
|
||||||
if (filteredServiceLines.length === 0) {
|
if (filteredServiceLines.length === 0) {
|
||||||
@@ -990,10 +1029,10 @@ export function ClaimForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
|
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
|
||||||
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
|
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((file) => ({
|
||||||
filename: f.name,
|
filename: file.name,
|
||||||
mimeType: f.type,
|
mimeType: file.type,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const selectedNpiProviderId = npiProvider?.npiNumber
|
const selectedNpiProviderId = npiProvider?.npiNumber
|
||||||
@@ -1014,7 +1053,7 @@ export function ClaimForm({
|
|||||||
|
|
||||||
// Send to CCA Selenium — send raw YYYY-MM-DD so Python _format_dob converts correctly
|
// Send to CCA Selenium — send raw YYYY-MM-DD so Python _format_dob converts correctly
|
||||||
onHandleForCCASeleniumClaim({
|
onHandleForCCASeleniumClaim({
|
||||||
...form,
|
...f,
|
||||||
serviceLines: filteredServiceLines,
|
serviceLines: filteredServiceLines,
|
||||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||||
patientId,
|
patientId,
|
||||||
@@ -1028,10 +1067,11 @@ export function ClaimForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Delta MA Claim: saves to DB then submits via Selenium
|
// Delta MA Claim: saves to DB then submits via Selenium
|
||||||
const handleDDMAClaim = async () => {
|
const handleDDMAClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
|
||||||
|
const f = formToUse ?? form;
|
||||||
const missingFields: string[] = [];
|
const missingFields: string[] = [];
|
||||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
if (!f.memberId?.trim()) missingFields.push("Member ID");
|
||||||
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
||||||
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -1042,7 +1082,7 @@ export function ClaimForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredServiceLines = (form.serviceLines || []).filter(
|
const filteredServiceLines = (f.serviceLines || []).filter(
|
||||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||||
);
|
);
|
||||||
if (filteredServiceLines.length === 0) {
|
if (filteredServiceLines.length === 0) {
|
||||||
@@ -1068,7 +1108,7 @@ export function ClaimForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
|
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
|
||||||
|
|
||||||
// Upload files to server so we get local filePaths for Selenium
|
// Upload files to server so we get local filePaths for Selenium
|
||||||
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
||||||
@@ -1091,7 +1131,7 @@ export function ClaimForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onHandleForDDMASeleniumClaim({
|
onHandleForDDMASeleniumClaim({
|
||||||
...form,
|
...f,
|
||||||
serviceLines: filteredServiceLines,
|
serviceLines: filteredServiceLines,
|
||||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||||
patientId,
|
patientId,
|
||||||
@@ -1106,10 +1146,11 @@ export function ClaimForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// United/DentalHub Claim: saves to DB then submits via Selenium
|
// United/DentalHub Claim: saves to DB then submits via Selenium
|
||||||
const handleUnitedDHClaim = async () => {
|
const handleUnitedDHClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
|
||||||
|
const f = formToUse ?? form;
|
||||||
const missingFields: string[] = [];
|
const missingFields: string[] = [];
|
||||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
if (!f.memberId?.trim()) missingFields.push("Member ID");
|
||||||
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
||||||
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -1120,7 +1161,7 @@ export function ClaimForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredServiceLines = (form.serviceLines || []).filter(
|
const filteredServiceLines = (f.serviceLines || []).filter(
|
||||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||||
);
|
);
|
||||||
if (filteredServiceLines.length === 0) {
|
if (filteredServiceLines.length === 0) {
|
||||||
@@ -1146,7 +1187,7 @@ export function ClaimForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
|
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
|
||||||
|
|
||||||
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
||||||
? await uploadAttachmentsToLocalFolder(uploadedFiles)
|
? await uploadAttachmentsToLocalFolder(uploadedFiles)
|
||||||
@@ -1168,7 +1209,7 @@ export function ClaimForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onHandleForUnitedDHSeleniumClaim({
|
onHandleForUnitedDHSeleniumClaim({
|
||||||
...form,
|
...f,
|
||||||
serviceLines: filteredServiceLines,
|
serviceLines: filteredServiceLines,
|
||||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||||
patientId,
|
patientId,
|
||||||
@@ -1183,10 +1224,11 @@ export function ClaimForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Tufts SCO Claim: saves to DB then submits via Selenium
|
// Tufts SCO Claim: saves to DB then submits via Selenium
|
||||||
const handleTuftsSCOClaim = async () => {
|
const handleTuftsSCOClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
|
||||||
|
const f = formToUse ?? form;
|
||||||
const missingFields: string[] = [];
|
const missingFields: string[] = [];
|
||||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
if (!f.memberId?.trim()) missingFields.push("Member ID");
|
||||||
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
|
||||||
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
if (!patient?.firstName?.trim()) missingFields.push("First Name");
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -1197,7 +1239,7 @@ export function ClaimForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredServiceLines = (form.serviceLines || []).filter(
|
const filteredServiceLines = (f.serviceLines || []).filter(
|
||||||
(line) => (line.procedureCode ?? "").trim() !== "",
|
(line) => (line.procedureCode ?? "").trim() !== "",
|
||||||
);
|
);
|
||||||
if (filteredServiceLines.length === 0) {
|
if (filteredServiceLines.length === 0) {
|
||||||
@@ -1223,7 +1265,7 @@ export function ClaimForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
|
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
|
||||||
|
|
||||||
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
|
||||||
? await uploadAttachmentsToLocalFolder(uploadedFiles)
|
? await uploadAttachmentsToLocalFolder(uploadedFiles)
|
||||||
@@ -1255,7 +1297,7 @@ export function ClaimForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onHandleForTuftsSCOSeleniumClaim({
|
onHandleForTuftsSCOSeleniumClaim({
|
||||||
...form,
|
...f,
|
||||||
serviceLines: filteredServiceLines,
|
serviceLines: filteredServiceLines,
|
||||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||||
patientId,
|
patientId,
|
||||||
@@ -1656,8 +1698,30 @@ export function ClaimForm({
|
|||||||
if (autoSubmittedRef.current) return;
|
if (autoSubmittedRef.current) return;
|
||||||
autoSubmittedRef.current = true;
|
autoSubmittedRef.current = true;
|
||||||
|
|
||||||
handleMHSubmit();
|
// Apply fee-schedule prices before triggering so billed amounts are populated
|
||||||
}, [autoSubmit, prefillDone, isFormReady]);
|
const siteKeyForPricing = autoSubmitSiteKey
|
||||||
|
? autoSubmitSiteKey.replace(/_/g, "").toLowerCase()
|
||||||
|
: deriveInsuranceSiteKey(form.insuranceProvider || "");
|
||||||
|
|
||||||
|
const pricedForm = mapPricesForForm({
|
||||||
|
form: { ...form, insuranceSiteKey: siteKeyForPricing },
|
||||||
|
patientDOB: patient?.dateOfBirth ?? "",
|
||||||
|
insuranceSiteKey: siteKeyForPricing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = (autoSubmitSiteKey ?? "").toLowerCase();
|
||||||
|
if (key === "tufts_sco" || key === "tuftsco" || key === "tufts sco") {
|
||||||
|
handleTuftsSCOClaim(pricedForm);
|
||||||
|
} else if (key === "cca") {
|
||||||
|
handleCCAClaim(pricedForm);
|
||||||
|
} else if (key === "ddma") {
|
||||||
|
handleDDMAClaim(pricedForm);
|
||||||
|
} else if (key === "united_sco" || key === "unitedco" || key === "dentalhub") {
|
||||||
|
handleUnitedDHClaim(pricedForm);
|
||||||
|
} else {
|
||||||
|
handleMHSubmit(pricedForm);
|
||||||
|
}
|
||||||
|
}, [autoSubmit, autoSubmitSiteKey, prefillDone, isFormReady]);
|
||||||
|
|
||||||
// overlay click handler (close when clicking backdrop)
|
// overlay click handler (close when clicking backdrop)
|
||||||
const onOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
const onOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Send,
|
Send,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -24,7 +25,8 @@ type Step =
|
|||||||
| "patient-found"
|
| "patient-found"
|
||||||
| "eligibility-id-ready"
|
| "eligibility-id-ready"
|
||||||
| "check-and-claim-ready"
|
| "check-and-claim-ready"
|
||||||
| "need-insurance-clarification";
|
| "need-insurance-clarification"
|
||||||
|
| "need-appointment-selection";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -96,14 +98,20 @@ function parseEligibilityInput(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_MESSAGES: Message[] = [
|
const CHAT_STORAGE_KEY = "chatbot_messages";
|
||||||
makeMsg("bot", "Hi! What can I help you with today?"),
|
|
||||||
];
|
function loadSavedMessages(): Message[] {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(CHAT_STORAGE_KEY);
|
||||||
|
if (raw) return JSON.parse(raw) as Message[];
|
||||||
|
} catch {}
|
||||||
|
return [makeMsg("bot", "Hi! What can I help you with today?")];
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatbotButton() {
|
export function ChatbotButton() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [step, setStep] = useState<Step>("menu");
|
const [step, setStep] = useState<Step>("menu");
|
||||||
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
const [messages, setMessages] = useState<Message[]>(loadSavedMessages);
|
||||||
const [pasteInput, setPasteInput] = useState("");
|
const [pasteInput, setPasteInput] = useState("");
|
||||||
const [parseError, setParseError] = useState("");
|
const [parseError, setParseError] = useState("");
|
||||||
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
|
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
|
||||||
@@ -112,6 +120,12 @@ export function ChatbotButton() {
|
|||||||
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null);
|
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null);
|
||||||
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | 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 [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
|
||||||
|
const [apptSelectionData, setApptSelectionData] = useState<{
|
||||||
|
patient: PatientResult;
|
||||||
|
siteKey: string;
|
||||||
|
matchedCodes: { code: string; description: string }[];
|
||||||
|
options: { label: string; appointmentId: number; serviceDate: string }[];
|
||||||
|
} | null>(null);
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -121,6 +135,14 @@ export function ChatbotButton() {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages, step]);
|
}, [messages, step]);
|
||||||
|
|
||||||
|
// Persist messages across navigation (cleared on logout)
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saveable = messages.filter((m) => !m.isLoading);
|
||||||
|
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable));
|
||||||
|
} catch {}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === "eligibility-input") {
|
if (step === "eligibility-input") {
|
||||||
setTimeout(() => pasteRef.current?.focus(), 50);
|
setTimeout(() => pasteRef.current?.focus(), 50);
|
||||||
@@ -141,9 +163,9 @@ export function ChatbotButton() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
const reset = () => {
|
// Resets step/data only — keeps message history
|
||||||
|
const resetStep = () => {
|
||||||
setStep("menu");
|
setStep("menu");
|
||||||
setMessages([makeMsg("bot", "Hi! What can I help you with today?")]);
|
|
||||||
setPasteInput("");
|
setPasteInput("");
|
||||||
setParseError("");
|
setParseError("");
|
||||||
setEligibilityData(null);
|
setEligibilityData(null);
|
||||||
@@ -152,22 +174,33 @@ export function ChatbotButton() {
|
|||||||
setEligibilityIdData(null);
|
setEligibilityIdData(null);
|
||||||
setCheckAndClaimData(null);
|
setCheckAndClaimData(null);
|
||||||
setClarificationData(null);
|
setClarificationData(null);
|
||||||
|
setApptSelectionData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full reset including message history and stored session
|
||||||
|
const reset = () => {
|
||||||
|
resetStep();
|
||||||
|
const fresh = [makeMsg("bot", "Hi! What can I help you with today?")];
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh));
|
||||||
|
} catch {}
|
||||||
|
setMessages(fresh);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
reset();
|
resetStep();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOptionSelect = (option: "eligibility" | "schedule" | "claims") => {
|
const handleOptionSelect = (option: "eligibility" | "schedule" | "claims") => {
|
||||||
if (option === "schedule") {
|
if (option === "schedule") {
|
||||||
addMsg("user", "Schedule an appointment");
|
addMsg("user", "Schedule an appointment");
|
||||||
addMsg("bot", "Opening the appointments page...");
|
addMsg("bot", "Opening the appointments page...");
|
||||||
setTimeout(() => { setLocation("/appointments"); setOpen(false); reset(); }, 600);
|
setTimeout(() => { setLocation("/appointments"); setOpen(false); resetStep(); }, 600);
|
||||||
} else if (option === "claims") {
|
} else if (option === "claims") {
|
||||||
addMsg("user", "View claims");
|
addMsg("user", "View claims");
|
||||||
addMsg("bot", "Opening the claims page...");
|
addMsg("bot", "Opening the claims page...");
|
||||||
setTimeout(() => { setLocation("/claims"); setOpen(false); reset(); }, 600);
|
setTimeout(() => { setLocation("/claims"); setOpen(false); resetStep(); }, 600);
|
||||||
} else if (option === "eligibility") {
|
} else if (option === "eligibility") {
|
||||||
addMsg("user", "Check Eligibility");
|
addMsg("user", "Check Eligibility");
|
||||||
addMsg("bot", "Please enter the patient's Member ID and Date of Birth:");
|
addMsg("bot", "Please enter the patient's Member ID and Date of Birth:");
|
||||||
@@ -199,7 +232,7 @@ export function ChatbotButton() {
|
|||||||
autoCheck: getAutoCheck(eligibilityData.dobISO),
|
autoCheck: getAutoCheck(eligibilityData.dobISO),
|
||||||
}));
|
}));
|
||||||
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
||||||
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEligibilityFromPatient = () => {
|
const handleEligibilityFromPatient = () => {
|
||||||
@@ -214,13 +247,13 @@ export function ChatbotButton() {
|
|||||||
}));
|
}));
|
||||||
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
||||||
}
|
}
|
||||||
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
|
||||||
};
|
};
|
||||||
|
|
||||||
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
|
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
|
||||||
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
|
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
|
||||||
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
||||||
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEligibilityIdRun = () => {
|
const handleEligibilityIdRun = () => {
|
||||||
@@ -257,13 +290,17 @@ export function ChatbotButton() {
|
|||||||
setStep("ai-loading");
|
setStep("ai-loading");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text });
|
const history = messages
|
||||||
|
.filter((m) => !m.isLoading)
|
||||||
|
.slice(-15)
|
||||||
|
.map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
|
||||||
|
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
||||||
|
|
||||||
if (data.action === "navigate" && data.actionData?.url) {
|
if (data.action === "navigate" && data.actionData?.url) {
|
||||||
setTimeout(() => { setLocation(data.actionData.url); setOpen(false); reset(); }, 800);
|
setTimeout(() => { setLocation(data.actionData.url); setOpen(false); resetStep(); }, 800);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +350,41 @@ export function ChatbotButton() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.action === "appointment_created") {
|
||||||
|
setStep("menu");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === "need_appointment_selection" && data.actionData) {
|
||||||
|
setApptSelectionData({
|
||||||
|
patient: data.actionData.patient,
|
||||||
|
siteKey: data.actionData.siteKey,
|
||||||
|
matchedCodes: data.actionData.matchedCodes ?? [],
|
||||||
|
options: data.actionData.options ?? [],
|
||||||
|
});
|
||||||
|
setStep("need-appointment-selection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === "claim_only_ready" && data.actionData) {
|
||||||
|
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
|
||||||
|
if (patient?.id && matchedCodes?.length > 0) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"chatbot_claim_prefill",
|
||||||
|
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = appointmentId
|
||||||
|
? `/claims?appointmentId=${appointmentId}`
|
||||||
|
: `/claims?newPatient=${patient?.id}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
setLocation(url);
|
||||||
|
setOpen(false);
|
||||||
|
resetStep();
|
||||||
|
}, 600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStep("menu");
|
setStep("menu");
|
||||||
} catch {
|
} catch {
|
||||||
replaceLastMsg("Sorry, something went wrong. Please try again.");
|
replaceLastMsg("Sorry, something went wrong. Please try again.");
|
||||||
@@ -333,7 +405,8 @@ export function ChatbotButton() {
|
|||||||
step === "patient-found" ||
|
step === "patient-found" ||
|
||||||
step === "eligibility-id-ready" ||
|
step === "eligibility-id-ready" ||
|
||||||
step === "check-and-claim-ready" ||
|
step === "check-and-claim-ready" ||
|
||||||
step === "need-insurance-clarification";
|
step === "need-insurance-clarification" ||
|
||||||
|
step === "need-appointment-selection";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -360,6 +433,16 @@ export function ChatbotButton() {
|
|||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
<span className="font-semibold text-sm">Assistant</span>
|
<span className="font-semibold text-sm">Assistant</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="flex items-center gap-1 text-xs hover:opacity-70 transition-opacity rounded px-1.5 py-0.5 border border-white/30 hover:bg-white/10"
|
||||||
|
title="Clear chat history"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="hover:opacity-70 transition-opacity rounded"
|
className="hover:opacity-70 transition-opacity rounded"
|
||||||
@@ -368,6 +451,7 @@ export function ChatbotButton() {
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
@@ -631,6 +715,44 @@ export function ChatbotButton() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Appointment selection */}
|
||||||
|
{step === "need-appointment-selection" && apptSelectionData && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-amber-800">Which appointment date?</p>
|
||||||
|
<div className="flex flex-col gap-1.5 pt-1">
|
||||||
|
{apptSelectionData.options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.serviceDate}
|
||||||
|
className="text-left text-xs px-3 py-2 rounded-lg border border-amber-300 hover:bg-amber-100 transition-colors font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
addMsg("user", opt.label);
|
||||||
|
addMsg("bot", `Using ${opt.label} as the service date.`);
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"chatbot_claim_prefill",
|
||||||
|
JSON.stringify({
|
||||||
|
codes: apptSelectionData.matchedCodes,
|
||||||
|
siteKey: apptSelectionData.siteKey,
|
||||||
|
serviceDate: opt.serviceDate,
|
||||||
|
autoSubmit: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
setLocation(`/claims?appointmentId=${opt.appointmentId}`);
|
||||||
|
setOpen(false);
|
||||||
|
resetStep();
|
||||||
|
}, 600);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={resetStep}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
|
sessionStorage.removeItem("chatbot_messages");
|
||||||
await apiRequest("POST", "/api/auth/logout");
|
await apiRequest("POST", "/api/auth/logout");
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ import { useLicense } from "@/hooks/use-license";
|
|||||||
|
|
||||||
export default function ClaimsPage() {
|
export default function ClaimsPage() {
|
||||||
const [isClaimFormOpen, setIsClaimFormOpen] = useState(false);
|
const [isClaimFormOpen, setIsClaimFormOpen] = useState(false);
|
||||||
const [selectedPatientId, setSelectedPatientId] = useState<number | null>(
|
const [selectedPatientId, setSelectedPatientId] = useState<number | null>(null);
|
||||||
null
|
const [chatbotAutoSubmitSiteKey, setChatbotAutoSubmitSiteKey] = useState<string | undefined>(undefined);
|
||||||
);
|
|
||||||
// for redirect from appointment page directly, then passing to claimform
|
// for redirect from appointment page directly, then passing to claimform
|
||||||
const [selectedAppointmentId, setSelectedAppointmentId] = useState<
|
const [selectedAppointmentId, setSelectedAppointmentId] = useState<
|
||||||
number | null
|
number | null
|
||||||
@@ -239,6 +238,17 @@ export default function ClaimsPage() {
|
|||||||
const id = Number(newPatient);
|
const id = Number(newPatient);
|
||||||
if (!Number.isFinite(id) || id <= 0) return;
|
if (!Number.isFinite(id) || id <= 0) return;
|
||||||
|
|
||||||
|
// Check if chatbot requested auto-submit with a specific insurance
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("chatbot_claim_prefill");
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed?.autoSubmit && parsed?.siteKey) {
|
||||||
|
setChatbotAutoSubmitSiteKey(parsed.siteKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
handleNewClaim(id);
|
handleNewClaim(id);
|
||||||
clearUrlParams(["newPatient"]);
|
clearUrlParams(["newPatient"]);
|
||||||
}, [newPatient]);
|
}, [newPatient]);
|
||||||
@@ -786,6 +796,7 @@ export default function ClaimsPage() {
|
|||||||
const closeClaim = () => {
|
const closeClaim = () => {
|
||||||
setSelectedPatientId(null);
|
setSelectedPatientId(null);
|
||||||
setSelectedAppointmentId(null);
|
setSelectedAppointmentId(null);
|
||||||
|
setChatbotAutoSubmitSiteKey(undefined);
|
||||||
setIsClaimFormOpen(false);
|
setIsClaimFormOpen(false);
|
||||||
|
|
||||||
clearUrlParams(["newPatient", "appointmentId"]);
|
clearUrlParams(["newPatient", "appointmentId"]);
|
||||||
@@ -904,7 +915,8 @@ export default function ClaimsPage() {
|
|||||||
<ClaimForm
|
<ClaimForm
|
||||||
patientId={selectedPatientId}
|
patientId={selectedPatientId}
|
||||||
appointmentId={selectedAppointmentId ?? undefined}
|
appointmentId={selectedAppointmentId ?? undefined}
|
||||||
autoSubmit={mode === "direct"}
|
autoSubmit={mode === "direct" || !!chatbotAutoSubmitSiteKey}
|
||||||
|
autoSubmitSiteKey={chatbotAutoSubmitSiteKey}
|
||||||
proceduresOnly={mode === "procedures"}
|
proceduresOnly={mode === "procedures"}
|
||||||
onClose={closeClaim}
|
onClose={closeClaim}
|
||||||
onSubmit={handleClaimSubmit}
|
onSubmit={handleClaimSubmit}
|
||||||
|
|||||||
@@ -179,8 +179,14 @@ export default function InsuranceStatusPage() {
|
|||||||
try {
|
try {
|
||||||
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
|
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
|
||||||
if (id) setMemberId(id);
|
if (id) setMemberId(id);
|
||||||
if (dob) setDateOfBirth(parseLocalDate(dob));
|
if (dob) {
|
||||||
if (ac === "mh" || ac === "cmsp") pendingAutoCheck.current = ac;
|
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD
|
||||||
|
const normalized = /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dob)
|
||||||
|
? (() => { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })()
|
||||||
|
: dob;
|
||||||
|
setDateOfBirth(parseLocalDate(normalized));
|
||||||
|
}
|
||||||
|
if (ac) pendingAutoCheck.current = ac;
|
||||||
sessionStorage.removeItem("chatbot_eligibility");
|
sessionStorage.removeItem("chatbot_eligibility");
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -211,6 +211,22 @@ class AutomationDentaQuestEligibilityCheck:
|
|||||||
password_field.clear()
|
password_field.clear()
|
||||||
password_field.send_keys(self.dentaquest_password)
|
password_field.send_keys(self.dentaquest_password)
|
||||||
|
|
||||||
|
# Check "Remember me" before signing in — mirrors DDMA to avoid OTP on next login
|
||||||
|
try:
|
||||||
|
remember_me = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH,
|
||||||
|
"//label[.//span[contains(text(),'Remember me')] or "
|
||||||
|
"contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember me')] | "
|
||||||
|
"//input[@type='checkbox' and ("
|
||||||
|
"contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember') or "
|
||||||
|
"contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember'))]"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
remember_me.click()
|
||||||
|
print("[DentaQuest login] Checked 'Remember me'")
|
||||||
|
except Exception:
|
||||||
|
print("[DentaQuest login] No 'Remember me' found on login page (continuing)")
|
||||||
|
|
||||||
# Click login button
|
# Click login button
|
||||||
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit']")))
|
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit']")))
|
||||||
login_button.click()
|
login_button.click()
|
||||||
|
|||||||
@@ -187,6 +187,22 @@ class AutomationTuftsSCOClaimSubmit:
|
|||||||
password_field.send_keys(self.dentaquest_password)
|
password_field.send_keys(self.dentaquest_password)
|
||||||
print("[TuftsSCO Claim login] Entered password")
|
print("[TuftsSCO Claim login] Entered password")
|
||||||
|
|
||||||
|
# Check "Remember me" before signing in — mirrors DDMA to avoid OTP on next login
|
||||||
|
try:
|
||||||
|
remember_me = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH,
|
||||||
|
"//label[.//span[contains(text(),'Remember me')] or "
|
||||||
|
"contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember me')] | "
|
||||||
|
"//input[@type='checkbox' and ("
|
||||||
|
"contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember') or "
|
||||||
|
"contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember'))]"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
remember_me.click()
|
||||||
|
print("[TuftsSCO Claim login] Checked 'Remember me'")
|
||||||
|
except Exception:
|
||||||
|
print("[TuftsSCO Claim login] No 'Remember me' found on login page (continuing)")
|
||||||
|
|
||||||
signin_btn = WebDriverWait(self.driver, 10).until(
|
signin_btn = WebDriverWait(self.driver, 10).until(
|
||||||
EC.element_to_be_clickable((By.XPATH,
|
EC.element_to_be_clickable((By.XPATH,
|
||||||
"//button[@type='submit'] | //input[@type='submit'] | "
|
"//button[@type='submit'] | //input[@type='submit'] | "
|
||||||
|
|||||||
Reference in New Issue
Block a user