- Member ID and DOB in the AI chat "patient found" confirmation card are now editable inputs, and edits are persisted back to the patient record before proceeding so corrections don't revert to stale values. - Fix auto-lookup-by-member-ID effect on the insurance status page clobbering a hand-edited DOB with the stale DB value during chatbot prefill. - CMSP (under-14) eligibility flow was missing the appointment-creation hook that the adult MassHealth flow already had, so under-14 patients never got scheduled after a chatbot-initiated "eligibility & appointment" request. - For already-known patients, the chatbot was creating the appointment before running the eligibility check instead of after; now eligibility is always checked first and the appointment created only afterward, and only when the resulting patient status is ACTIVE.
1560 lines
60 KiB
TypeScript
Executable File
1560 lines
60 KiB
TypeScript
Executable File
import { useEffect, useRef, useState } from "react";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { CheckCircle, LoaderCircleIcon, Bot, PhoneCall, ChevronDown, ChevronUp } from "lucide-react";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { PatientTable } from "@/components/patients/patient-table";
|
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
import { useAppDispatch, useAppSelector } from "@/redux/hooks";
|
|
import {
|
|
setTaskStatus,
|
|
clearTaskStatus,
|
|
} from "@/redux/slices/seleniumTaskSlice";
|
|
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
|
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
|
import { socket } from "@/lib/socket";
|
|
import { InsertPatient, Patient } from "@repo/db/types";
|
|
import { DateInput } from "@/components/ui/dateInput";
|
|
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
|
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
|
import { DualPdfPreviewModal } from "@/components/insurance-status/dual-pdf-preview-modal";
|
|
import { useLocation } from "wouter";
|
|
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
|
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
|
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
|
|
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
|
|
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
|
|
import { BcbsMaEligibilityButton } from "@/components/insurance-status/bcbs-ma-button-modal";
|
|
import { useLicense } from "@/hooks/use-license";
|
|
|
|
/**
|
|
* Waits for a Selenium job to complete by racing socket.io events against
|
|
* HTTP polling every 3 seconds. This ensures the result is received even
|
|
* when the socket reconnects through Cloudflare (changing socket.id).
|
|
*/
|
|
function waitForSeleniumJob(
|
|
jobId: string,
|
|
onActive?: (message: string) => void,
|
|
): Promise<any> {
|
|
return new Promise<any>((resolve, reject) => {
|
|
let settled = false;
|
|
|
|
const settle = (fn: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearInterval(pollInterval);
|
|
socket.off("job:update", socketHandler);
|
|
fn();
|
|
};
|
|
|
|
const socketHandler = (payload: any) => {
|
|
if (String(payload.jobId) !== String(jobId)) return;
|
|
if (payload.status === "active") {
|
|
onActive?.(payload.message ?? "Selenium running...");
|
|
} else if (payload.status === "completed") {
|
|
settle(() => resolve(payload.result ?? {}));
|
|
} else if (payload.status === "failed") {
|
|
settle(() => reject(new Error(payload.error ?? "Selenium job failed")));
|
|
}
|
|
};
|
|
socket.on("job:update", socketHandler);
|
|
|
|
const pollInterval = setInterval(async () => {
|
|
if (settled) return;
|
|
try {
|
|
const res = await apiRequest("GET", `/api/insurance-status/job-status/${jobId}`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
if (data.status === "completed") {
|
|
settle(() => resolve(data.result ?? {}));
|
|
} else if (data.status === "failed") {
|
|
settle(() => reject(new Error(data.error ?? "Selenium job failed")));
|
|
}
|
|
} catch {
|
|
// ignore transient poll errors
|
|
}
|
|
}, 3000);
|
|
});
|
|
}
|
|
|
|
const PENDING_COPY_AGENT_SCREENSHOTS_KEY = "copy_agent_pending_screenshots";
|
|
|
|
export default function InsuranceStatusPage() {
|
|
const { user } = useAuth();
|
|
const { toast } = useToast();
|
|
const { isLicensed } = useLicense();
|
|
const dispatch = useAppDispatch();
|
|
const { status, message, show } = useAppSelector(
|
|
(state) => state.seleniumTasks.eligibilityCheck,
|
|
);
|
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
|
const [location, setLocation] = useLocation();
|
|
|
|
// Insurance eligibility and claim check form fields
|
|
const [memberId, setMemberId] = useState("");
|
|
const [dateOfBirth, setDateOfBirth] = useState<Date | null>(null);
|
|
const [firstName, setFirstName] = useState("");
|
|
const [lastName, setLastName] = useState("");
|
|
const isFormIncomplete = !memberId || !dateOfBirth;
|
|
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
|
|
useState(false);
|
|
const [isCheckingEligibilityAppointment, setIsCheckingEligibilityAppointment] =
|
|
useState(false);
|
|
const [isCheckingEligibilityClaimsPreAuth, setIsCheckingEligibilityClaimsPreAuth] =
|
|
useState(false);
|
|
const [isCheckingEligibilityHistory, setIsCheckingEligibilityHistory] =
|
|
useState(false);
|
|
const [isCheckingCMSP, setIsCheckingCMSP] = useState(false);
|
|
|
|
// AI Call Insurance section
|
|
const [aiCallOpen, setAiCallOpen] = useState(false);
|
|
const [aiSelectedContactId, setAiSelectedContactId] = useState<string>("");
|
|
const [aiCallNotes, setAiCallNotes] = useState("");
|
|
|
|
type InsuranceContact = { id: number; name: string; phoneNumber?: string | null };
|
|
const { data: insuranceContacts = [] } = useQuery<InsuranceContact[]>({
|
|
queryKey: ["/api/insurance-contacts"],
|
|
queryFn: async () => {
|
|
const res = await apiRequest("GET", "/api/insurance-contacts");
|
|
if (!res.ok) return [];
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
const selectedInsuranceContact = insuranceContacts.find(
|
|
(c) => String(c.id) === aiSelectedContactId
|
|
) ?? null;
|
|
|
|
// PDF preview modal state
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [previewPdfId, setPreviewPdfId] = useState<number | string | null>(null);
|
|
const [previewFallbackFilename, setPreviewFallbackFilename] = useState<
|
|
string | null
|
|
>(null);
|
|
|
|
// Dual PDF modal state (used by MH Eligibility & History)
|
|
const [dualPreviewOpen, setDualPreviewOpen] = useState(false);
|
|
const [dualEligibilityPdfId, setDualEligibilityPdfId] = useState<number | string | null>(null);
|
|
const [dualEligibilityFilename, setDualEligibilityFilename] = useState<string | null>(null);
|
|
const [dualMemberDetailsPdfId, setDualMemberDetailsPdfId] = useState<number | string | null>(null);
|
|
const [dualMemberDetailsFilename, setDualMemberDetailsFilename] = useState<string | null>(null);
|
|
const [dualHistoryPdfId, setDualHistoryPdfId] = useState<number | string | null>(null);
|
|
const [dualHistoryFilename, setDualHistoryFilename] = useState<string | null>(null);
|
|
|
|
// CMSP PDF modal state (used by CMSP Eligibility & History & Remaining)
|
|
const [cmspPreviewOpen, setCmspPreviewOpen] = useState(false);
|
|
const [cmspEligibilityPdfId, setCmspEligibilityPdfId] = useState<number | string | null>(null);
|
|
const [cmspEligibilityFilename, setCmspEligibilityFilename] = useState<string | null>(null);
|
|
const [cmspMemberDetailsPdfId, setCmspMemberDetailsPdfId] = useState<number | string | null>(null);
|
|
const [cmspMemberDetailsFilename, setCmspMemberDetailsFilename] = useState<string | null>(null);
|
|
const [cmspHistoryPdfId, setCmspHistoryPdfId] = useState<number | string | null>(null);
|
|
const [cmspHistoryFilename, setCmspHistoryFilename] = useState<string | null>(null);
|
|
const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState<number | string | null>(null);
|
|
const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState<string | 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 pendingScrollTo = useRef<string | null>(null);
|
|
const [prefillTick, setPrefillTick] = useState(0);
|
|
// Set when the chatbot prefill supplies a DOB, so the auto-lookup-by-member-ID effect below
|
|
// doesn't clobber a deliberately hand-edited DOB with the (possibly stale/wrong) one on file.
|
|
const suppressAutoLookupDobRef = useRef(false);
|
|
|
|
// Prefill from chatbot
|
|
useEffect(() => {
|
|
const apply = () => {
|
|
const raw = sessionStorage.getItem("chatbot_eligibility");
|
|
if (!raw) return;
|
|
try {
|
|
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
|
|
if (id) setMemberId(id);
|
|
if (dob) {
|
|
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD.
|
|
// A malformed date (e.g. hand-edited in the chatbot) must not abort the rest of
|
|
// this function — otherwise pendingAutoCheck/prefillTick below never get set and
|
|
// the auto-trigger effect silently never fires (no error, Selenium never launches).
|
|
try {
|
|
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));
|
|
suppressAutoLookupDobRef.current = true;
|
|
} catch (dobErr) {
|
|
console.error("[insurance-status] failed to parse prefilled DOB:", dob, dobErr);
|
|
toast({
|
|
title: "Invalid Date of Birth",
|
|
description: `Couldn't parse "${dob}" — please re-enter the Date of Birth below and try again.`,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
}
|
|
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 (err) {
|
|
console.error("[insurance-status] failed to apply chatbot eligibility prefill:", err);
|
|
}
|
|
};
|
|
apply();
|
|
window.addEventListener("chatbot:eligibility-prefill", apply);
|
|
return () =>
|
|
window.removeEventListener("chatbot:eligibility-prefill", apply);
|
|
}, []);
|
|
|
|
// Populate fields from selected patient
|
|
const didMountSelectedPatientEffect = useRef(false);
|
|
useEffect(() => {
|
|
if (selectedPatient) {
|
|
setMemberId(selectedPatient.insuranceId ?? "");
|
|
setFirstName(selectedPatient.firstName ?? "");
|
|
setLastName(selectedPatient.lastName ?? "");
|
|
|
|
const dob =
|
|
typeof selectedPatient.dateOfBirth === "string"
|
|
? parseLocalDate(selectedPatient.dateOfBirth)
|
|
: selectedPatient.dateOfBirth;
|
|
setDateOfBirth(dob ?? null);
|
|
} else if (didMountSelectedPatientEffect.current) {
|
|
// Only clear on an actual deselection (user action) — not on the initial mount, where
|
|
// selectedPatient starts null by default and would otherwise wipe out values the
|
|
// chatbot-prefill effect (above) just set from sessionStorage on this same page load.
|
|
setMemberId("");
|
|
setFirstName("");
|
|
setLastName("");
|
|
setDateOfBirth(null);
|
|
}
|
|
didMountSelectedPatientEffect.current = true;
|
|
}, [selectedPatient]);
|
|
|
|
// Auto-lookup patient by member ID when typed manually
|
|
useEffect(() => {
|
|
if (selectedPatient && memberId === (selectedPatient.insuranceId ?? "")) return;
|
|
if (!memberId) return;
|
|
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const res = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(memberId)}`);
|
|
if (!res.ok) return;
|
|
const patient: Patient | null = await res.json();
|
|
if (patient) {
|
|
setFirstName(patient.firstName ?? "");
|
|
setLastName(patient.lastName ?? "");
|
|
if (suppressAutoLookupDobRef.current) {
|
|
suppressAutoLookupDobRef.current = false;
|
|
} else {
|
|
const dob =
|
|
typeof patient.dateOfBirth === "string"
|
|
? parseLocalDate(patient.dateOfBirth)
|
|
: patient.dateOfBirth ?? null;
|
|
setDateOfBirth(dob);
|
|
}
|
|
}
|
|
} catch {
|
|
// silently ignore lookup errors
|
|
}
|
|
}, 500);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [memberId, selectedPatient]);
|
|
|
|
// Add patient mutation
|
|
const addPatientMutation = useMutation({
|
|
mutationFn: async (patient: InsertPatient) => {
|
|
const res = await apiRequest("POST", "/api/patients/", patient);
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
|
toast({
|
|
title: "Success",
|
|
description: "Patient added successfully!",
|
|
variant: "default",
|
|
});
|
|
},
|
|
onError: (error: any) => {
|
|
const msg = error.message;
|
|
|
|
if (msg === "A patient with this insurance ID already exists.") {
|
|
toast({
|
|
title: "Patient already exists",
|
|
description: msg,
|
|
variant: "destructive",
|
|
});
|
|
} else {
|
|
toast({
|
|
title: "Error",
|
|
description: `Failed to add patient: ${msg}`,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
const isUnder14 = (dob: Date): boolean => {
|
|
const cutoff = new Date(dob);
|
|
cutoff.setFullYear(cutoff.getFullYear() + 14);
|
|
return new Date() < cutoff;
|
|
};
|
|
|
|
// If the Copy Agent page couldn't read a Member ID/DOB from a screenshot, it stashes the
|
|
// screenshot(s) here and lets the user complete the check manually. Once that check resolves
|
|
// to a real patient, attach the originally-captured screenshots to that patient's Attachments.
|
|
const flushPendingCopyAgentScreenshots = async (jobResult: any) => {
|
|
let raw: string | null = null;
|
|
try {
|
|
raw = sessionStorage.getItem(PENDING_COPY_AGENT_SCREENSHOTS_KEY);
|
|
} catch {}
|
|
if (!raw || !jobResult?.patientId) return;
|
|
|
|
try {
|
|
sessionStorage.removeItem(PENDING_COPY_AGENT_SCREENSHOTS_KEY);
|
|
const items: { dataUrl: string; fileName: string }[] = JSON.parse(raw);
|
|
if (!items.length) return;
|
|
|
|
let patientName = `Patient ${jobResult.patientId}`;
|
|
try {
|
|
const res = await apiRequest("GET", `/api/patients/${jobResult.patientId}`);
|
|
const patient: Patient = await res.json();
|
|
const full = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
|
if (full) patientName = full;
|
|
} catch {}
|
|
|
|
const formData = new FormData();
|
|
formData.append("patientId", String(jobResult.patientId));
|
|
formData.append("patientName", patientName);
|
|
formData.append("screenshotBackup", "true");
|
|
for (const item of items) {
|
|
const blobRes = await fetch(item.dataUrl);
|
|
const blob = await blobRes.blob();
|
|
formData.append("files", new File([blob], `${item.fileName}.png`, { type: "image/png" }));
|
|
}
|
|
|
|
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
|
|
toast({
|
|
title: "Screenshots attached",
|
|
description: `Screenshots from Copy Agent were attached to ${patientName}'s attachments.`,
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to attach pending Copy Agent screenshots:", err);
|
|
}
|
|
};
|
|
|
|
// Shared: run MH eligibility selenium job, return jobResult or throw
|
|
const runMHEligibilitySelenium = async (): Promise<any> => {
|
|
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
|
|
|
const data = {
|
|
memberId,
|
|
dateOfBirth: formattedDob,
|
|
insuranceSiteKey: "MH",
|
|
firstName: firstName || undefined,
|
|
lastName: lastName || undefined,
|
|
};
|
|
|
|
dispatch(
|
|
setTaskStatus({
|
|
key: "eligibilityCheck",
|
|
status: "pending",
|
|
message: "Sending Data to Selenium...",
|
|
}),
|
|
);
|
|
|
|
const response = await apiRequest(
|
|
"POST",
|
|
"/api/insurance-status/eligibility-check",
|
|
{ data: data, socketId: socket.id },
|
|
);
|
|
const enqueueResult = await response.json();
|
|
if (enqueueResult.error) throw new Error(enqueueResult.error);
|
|
|
|
const jobId = enqueueResult.jobId;
|
|
if (!jobId) throw new Error("No jobId returned from server");
|
|
|
|
dispatch(
|
|
setTaskStatus({
|
|
key: "eligibilityCheck",
|
|
status: "pending",
|
|
message: "Selenium browser starting...",
|
|
}),
|
|
);
|
|
|
|
const jobResult = await waitForSeleniumJob(jobId, (msg) =>
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
|
|
);
|
|
await flushPendingCopyAgentScreenshots(jobResult);
|
|
return jobResult;
|
|
};
|
|
|
|
const handleAddPatient = async () => {
|
|
const newPatient: InsertPatient = {
|
|
firstName,
|
|
lastName,
|
|
dateOfBirth: dateOfBirth,
|
|
gender: "",
|
|
phone: "",
|
|
userId: user?.id ?? 1,
|
|
insuranceId: memberId,
|
|
};
|
|
await addPatientMutation.mutateAsync(newPatient);
|
|
};
|
|
|
|
// MH Eligibility — check eligibility, save to DB, open PDF preview
|
|
const handleMHEligibilityButton = async () => {
|
|
if (!memberId || !dateOfBirth) {
|
|
toast({
|
|
title: "Missing Fields",
|
|
description: "Please fill in all the required fields: Member ID, Date of Birth.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (isUnder14(dateOfBirth)) {
|
|
toast({ title: "CMSP Patient", description: "Patient is under 14 — running CMSP Eligibility & History & Remaining instead.", variant: "default" });
|
|
return handleCMSPButton();
|
|
}
|
|
|
|
setIsCheckingEligibilityStatus(true);
|
|
try {
|
|
const jobResult = await runMHEligibilitySelenium();
|
|
|
|
dispatch(
|
|
setTaskStatus({
|
|
key: "eligibilityCheck",
|
|
status: "success",
|
|
message: "Patient status is updated, and its eligibility pdf is uploaded at Document Page.",
|
|
}),
|
|
);
|
|
toast({
|
|
title: "Selenium service done.",
|
|
description: "Your Patient Eligibility is fetched and updated, Kindly search through the patient.",
|
|
variant: "default",
|
|
});
|
|
|
|
setSelectedPatient(null);
|
|
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
|
void tryAppointmentFromChatbot();
|
|
|
|
if (jobResult.pdfFileId) {
|
|
setPreviewPdfId(jobResult.pdfFileId);
|
|
setPreviewFallbackFilename(jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`);
|
|
setPreviewOpen(true);
|
|
}
|
|
processNextInQueue();
|
|
} catch (error: any) {
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" }));
|
|
toast({ title: "Selenium service error", description: error.message || "An error occurred.", variant: "destructive" });
|
|
} finally {
|
|
setIsCheckingEligibilityStatus(false);
|
|
}
|
|
};
|
|
|
|
// MH Eligibility & Appointment — check eligibility, save to DB, create appointment for today
|
|
const handleMHEligibilityAppointmentButton = async () => {
|
|
if (!memberId || !dateOfBirth) {
|
|
toast({
|
|
title: "Missing Fields",
|
|
description: "Please fill in all the required fields: Member ID, Date of Birth.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsCheckingEligibilityAppointment(true);
|
|
try {
|
|
const jobResult = await runMHEligibilitySelenium();
|
|
|
|
const isInactive = jobResult?.patientUpdateStatus?.includes("INACTIVE");
|
|
|
|
if (isInactive) {
|
|
dispatch(
|
|
setTaskStatus({
|
|
key: "eligibilityCheck",
|
|
status: "error",
|
|
message: "Insurance is inactive. No appointment created.",
|
|
}),
|
|
);
|
|
toast({
|
|
title: "Insurance Inactive",
|
|
description: "Patient insurance is inactive. No appointment was created.",
|
|
variant: "destructive",
|
|
});
|
|
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
|
return;
|
|
}
|
|
|
|
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
|
|
|
// Look up the patient (created/updated by the Selenium worker) then create today's appointment
|
|
let apptTime: string | null = null;
|
|
try {
|
|
const lookupRes = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(memberId)}`);
|
|
if (lookupRes.ok) {
|
|
const patient = await lookupRes.json();
|
|
if (patient?.id) {
|
|
const apptRes = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patient.id });
|
|
if (apptRes.ok) {
|
|
const apptData = await apptRes.json();
|
|
apptTime = apptData.startTime ? `${apptData.startTime} (${apptData.column ?? "Column A"})` : null;
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
dispatch(
|
|
setTaskStatus({
|
|
key: "eligibilityCheck",
|
|
status: "success",
|
|
message: apptTime
|
|
? `Eligibility checked. Appointment created at ${apptTime}.`
|
|
: "Eligibility checked. Could not create appointment — please add manually.",
|
|
}),
|
|
);
|
|
toast({
|
|
title: "Eligibility checked.",
|
|
description: apptTime
|
|
? `Patient added to today's schedule at ${apptTime}.`
|
|
: "Eligibility saved. Could not create appointment — Column A may be full.",
|
|
variant: "default",
|
|
});
|
|
|
|
setSelectedPatient(null);
|
|
|
|
if (jobResult.pdfFileId) {
|
|
setPreviewPdfId(jobResult.pdfFileId);
|
|
setPreviewFallbackFilename(jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`);
|
|
setPreviewOpen(true);
|
|
}
|
|
} catch (error: any) {
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" }));
|
|
toast({ title: "Selenium service error", description: error.message || "An error occurred.", variant: "destructive" });
|
|
} finally {
|
|
setIsCheckingEligibilityAppointment(false);
|
|
}
|
|
};
|
|
|
|
// MH Eligibility & Claims/PreAuth — check eligibility, save to DB, navigate to claims
|
|
const handleMHEligibilityClaimsPreAuthButton = async () => {
|
|
if (!memberId || !dateOfBirth) {
|
|
toast({
|
|
title: "Missing Fields",
|
|
description: "Please fill in all the required fields: Member ID, Date of Birth.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsCheckingEligibilityClaimsPreAuth(true);
|
|
try {
|
|
await runMHEligibilitySelenium();
|
|
|
|
dispatch(
|
|
setTaskStatus({
|
|
key: "eligibilityCheck",
|
|
status: "success",
|
|
message: "Eligibility checked and saved. Redirecting to Claims/PreAuth...",
|
|
}),
|
|
);
|
|
toast({
|
|
title: "Eligibility checked.",
|
|
description: "Patient eligibility saved. Redirecting to Claims/PreAuth.",
|
|
variant: "default",
|
|
});
|
|
|
|
setSelectedPatient(null);
|
|
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
|
setLocation("/claims");
|
|
} catch (error: any) {
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" }));
|
|
toast({ title: "Selenium service error", description: error.message || "An error occurred.", variant: "destructive" });
|
|
} finally {
|
|
setIsCheckingEligibilityClaimsPreAuth(false);
|
|
}
|
|
};
|
|
|
|
// MH Eligibility & History — eligibility check + service history, saves both PDFs, auto-downloads eligibility PDF only
|
|
const handleMHEligibilityHistoryButton = async () => {
|
|
if (!memberId || !dateOfBirth) {
|
|
toast({
|
|
title: "Missing Fields",
|
|
description: "Please fill in all the required fields: Member ID, Date of Birth.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (isUnder14(dateOfBirth)) {
|
|
toast({ title: "CMSP Patient", description: "Patient is under 14 — running CMSP Eligibility & History & Remaining instead.", variant: "default" });
|
|
return handleCMSPButton();
|
|
}
|
|
|
|
setIsCheckingEligibilityHistory(true);
|
|
try {
|
|
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
|
const data = {
|
|
memberId,
|
|
dateOfBirth: formattedDob,
|
|
insuranceSiteKey: "MH",
|
|
firstName: firstName || undefined,
|
|
lastName: lastName || undefined,
|
|
};
|
|
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Sending Data to Selenium..." }));
|
|
|
|
const response = await apiRequest(
|
|
"POST",
|
|
"/api/insurance-status/eligibility-history-check",
|
|
{ data, socketId: socket.id },
|
|
);
|
|
const enqueueResult = await response.json();
|
|
if (enqueueResult.error) throw new Error(enqueueResult.error);
|
|
|
|
const jobId = enqueueResult.jobId;
|
|
if (!jobId) throw new Error("No jobId returned from server");
|
|
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Selenium browser starting..." }));
|
|
|
|
const jobResult = await waitForSeleniumJob(jobId, (msg) =>
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
|
|
);
|
|
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "Eligibility and service history PDFs saved to Documents." }));
|
|
toast({
|
|
title: "Done",
|
|
description: "Eligibility and service history PDFs saved. Eligibility PDF downloading now.",
|
|
variant: "default",
|
|
});
|
|
|
|
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
|
void tryAppointmentFromChatbot();
|
|
if (claimed) return;
|
|
setSelectedPatient(null);
|
|
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
|
processNextInQueue();
|
|
|
|
// Open all PDFs side by side in the modal
|
|
if (jobResult.pdfFileId || jobResult.memberDetailsPdfFileId || jobResult.historyPdfFileId) {
|
|
setDualEligibilityPdfId(jobResult.pdfFileId ?? null);
|
|
setDualEligibilityFilename(jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`);
|
|
setDualMemberDetailsPdfId(jobResult.memberDetailsPdfFileId ?? null);
|
|
setDualMemberDetailsFilename(jobResult.memberDetailsPdfFilename ?? `eligibility_member_details_${memberId}.pdf`);
|
|
setDualHistoryPdfId(jobResult.historyPdfFileId ?? null);
|
|
setDualHistoryFilename(jobResult.historyPdfFilename ?? `eligibility_history_${memberId}.pdf`);
|
|
setDualPreviewOpen(true);
|
|
}
|
|
} catch (error: any) {
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" }));
|
|
toast({ title: "Selenium service error", description: error.message || "An error occurred.", variant: "destructive" });
|
|
} finally {
|
|
setIsCheckingEligibilityHistory(false);
|
|
}
|
|
};
|
|
|
|
// CMSP Eligibility & History & Remaining — eligibility + service history + accumulator PDFs
|
|
const handleCMSPButton = async () => {
|
|
if (!memberId || !dateOfBirth) {
|
|
toast({
|
|
title: "Missing Fields",
|
|
description: "Please fill in Member ID and Date of Birth.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsCheckingCMSP(true);
|
|
try {
|
|
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
|
const data = {
|
|
memberId,
|
|
dateOfBirth: formattedDob,
|
|
insuranceSiteKey: "MH",
|
|
firstName: firstName || undefined,
|
|
lastName: lastName || undefined,
|
|
};
|
|
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Sending Data to Selenium..." }));
|
|
|
|
const response = await apiRequest(
|
|
"POST",
|
|
"/api/insurance-status/cmsp-eligibility-history-remaining-check",
|
|
{ data, socketId: socket.id },
|
|
);
|
|
const enqueueResult = await response.json();
|
|
if (enqueueResult.error) throw new Error(enqueueResult.error);
|
|
|
|
const jobId = enqueueResult.jobId;
|
|
if (!jobId) throw new Error("No jobId returned from server");
|
|
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Selenium browser starting..." }));
|
|
|
|
const jobResult = await waitForSeleniumJob(jobId, (msg) =>
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
|
|
);
|
|
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "CMSP PDFs saved to Documents." }));
|
|
toast({
|
|
title: "Done",
|
|
description: "Eligibility, history, and accumulator PDFs saved.",
|
|
variant: "default",
|
|
});
|
|
|
|
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
|
void tryAppointmentFromChatbot();
|
|
if (claimed) return;
|
|
setSelectedPatient(null);
|
|
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
|
processNextInQueue();
|
|
|
|
// Open 4-panel modal
|
|
if (jobResult.pdfFileId || jobResult.memberDetailsPdfFileId || jobResult.historyPdfFileId || jobResult.accumulatorPdfFileId) {
|
|
setCmspEligibilityPdfId(jobResult.pdfFileId ?? null);
|
|
setCmspEligibilityFilename(jobResult.pdfFilename ?? `cmsp_eligibility_${memberId}.pdf`);
|
|
setCmspMemberDetailsPdfId(jobResult.memberDetailsPdfFileId ?? null);
|
|
setCmspMemberDetailsFilename(jobResult.memberDetailsPdfFilename ?? `cmsp_member_details_${memberId}.pdf`);
|
|
setCmspHistoryPdfId(jobResult.historyPdfFileId ?? null);
|
|
setCmspHistoryFilename(jobResult.historyPdfFilename ?? `cmsp_history_${memberId}.pdf`);
|
|
setCmspAccumulatorPdfId(jobResult.accumulatorPdfFileId ?? null);
|
|
setCmspAccumulatorFilename(jobResult.accumulatorPdfFilename ?? `cmsp_accumulator_${memberId}.pdf`);
|
|
setCmspPreviewOpen(true);
|
|
}
|
|
} catch (error: any) {
|
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" }));
|
|
toast({ title: "Selenium service error", description: error.message || "An error occurred.", variant: "destructive" });
|
|
} finally {
|
|
setIsCheckingCMSP(false);
|
|
}
|
|
};
|
|
|
|
// Auto-trigger after prefill (from chatbot or schedule page "Check Eligibility")
|
|
useEffect(() => {
|
|
if (!pendingAutoCheck.current || !memberId || !dateOfBirth) return;
|
|
const check = pendingAutoCheck.current;
|
|
pendingAutoCheck.current = null;
|
|
if (check === "mh" || check === "mh-history") {
|
|
if (isUnder14(dateOfBirth)) {
|
|
handleCMSPButton();
|
|
} else {
|
|
handleMHEligibilityHistoryButton();
|
|
}
|
|
} else if (check === "cmsp") {
|
|
handleCMSPButton();
|
|
} else {
|
|
// Button-component-handled providers: pass target to their autoTrigger prop
|
|
setTriggerTarget(check);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [memberId, dateOfBirth, prefillTick]);
|
|
|
|
// small helper: remove given query params from the current URL (silent, no reload)
|
|
const clearUrlParams = (params: string[]) => {
|
|
try {
|
|
const url = new URL(window.location.href);
|
|
let changed = false;
|
|
for (const p of params) {
|
|
if (url.searchParams.has(p)) {
|
|
url.searchParams.delete(p);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
window.history.replaceState({}, document.title, url.toString());
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
// After any eligibility check succeeds, check if chatbot wanted auto-claim.
|
|
// PDF is already saved to Documents by the Selenium worker before this is called.
|
|
// Returns true if a chatbot claim was pending (caller should skip opening the preview).
|
|
const tryClaimFromChatbot = async (resolvedPatientId?: number | null): Promise<boolean> => {
|
|
try {
|
|
const raw = sessionStorage.getItem("chatbot_claim_codes");
|
|
if (!raw) return false;
|
|
const { codes, siteKey, patientId, memberId: storedMemberId, dob, serviceDate, renderingProvider } = JSON.parse(raw);
|
|
sessionStorage.removeItem("chatbot_claim_codes");
|
|
|
|
let pid: number | null = resolvedPatientId ?? patientId ?? null;
|
|
|
|
// Fallback: look up patient by memberId if patientId wasn't stored
|
|
if (!pid && storedMemberId) {
|
|
try {
|
|
const res = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(storedMemberId)}`);
|
|
if (res.ok) {
|
|
const p = await res.json();
|
|
pid = p?.id ?? null;
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (!codes?.length || !pid) return false;
|
|
|
|
sessionStorage.setItem(
|
|
"chatbot_claim_prefill",
|
|
JSON.stringify({ codes, siteKey, serviceDate: serviceDate ?? null, autoSubmit: true, renderingProvider: renderingProvider ?? null, dob: dob ?? null })
|
|
);
|
|
setLocation(`/claims?newPatient=${pid}`);
|
|
return true;
|
|
} catch {}
|
|
return false;
|
|
};
|
|
|
|
// After any eligibility check succeeds, check if chatbot wanted an appointment created.
|
|
// Patient was just created/updated in DB by the Selenium worker before this is called.
|
|
const tryAppointmentFromChatbot = async (): Promise<void> => {
|
|
try {
|
|
// Single-patient flow
|
|
const raw = sessionStorage.getItem("chatbot_appt_after_eligibility");
|
|
// Batch flow: create appointment for the current patient after each eligibility check
|
|
const isBatch = sessionStorage.getItem("chatbot_batch_appt_after_eligibility") === "true";
|
|
|
|
const useMemberId = raw ? JSON.parse(raw).memberId : isBatch ? memberId : null;
|
|
const useDate = raw ? JSON.parse(raw).date : null;
|
|
if (raw) sessionStorage.removeItem("chatbot_appt_after_eligibility");
|
|
// Clean up batch flag only when no more patients in queue
|
|
if (isBatch && !sessionStorage.getItem("chatbot_eligibility_queue")) {
|
|
sessionStorage.removeItem("chatbot_batch_appt_after_eligibility");
|
|
}
|
|
|
|
if (!useMemberId) return;
|
|
|
|
const lookupRes = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(useMemberId)}`);
|
|
if (!lookupRes.ok) return;
|
|
const patient = await lookupRes.json();
|
|
if (!patient?.id) return;
|
|
|
|
// The eligibility check that just ran saved the result to patient.status — only book
|
|
// the appointment when insurance came back ACTIVE.
|
|
if (patient.status !== "ACTIVE") {
|
|
toast({
|
|
title: "No appointment created",
|
|
description: `${patient.firstName ?? ""} ${patient.lastName ?? ""} insurance is ${patient.status === "INACTIVE" ? "inactive" : "not active"} — please add manually if needed.`.trim(),
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const apptRes = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patient.id, date: useDate ?? undefined });
|
|
const apptData = await apptRes.json();
|
|
if (apptRes.ok) {
|
|
const scheduledOn = useDate
|
|
? new Date(useDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
: "today";
|
|
toast({
|
|
title: "Appointment created",
|
|
description: `${patient.firstName ?? ""} ${patient.lastName ?? ""} added to schedule (${scheduledOn}) at ${apptData.startTime} (${apptData.column ?? "Column A"}).`.trim(),
|
|
});
|
|
} else {
|
|
toast({
|
|
title: "Could not create appointment",
|
|
description: apptData.message ?? "Please add manually.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch {}
|
|
};
|
|
|
|
const processNextInQueue = () => {
|
|
try {
|
|
const raw = sessionStorage.getItem("chatbot_eligibility_queue");
|
|
if (!raw) return;
|
|
const queue = JSON.parse(raw) as { memberId: string; dob: string; autoCheck: string }[];
|
|
if (!queue.length) {
|
|
sessionStorage.removeItem("chatbot_eligibility_queue");
|
|
return;
|
|
}
|
|
const [next, ...rest] = queue;
|
|
if (rest.length > 0) {
|
|
sessionStorage.setItem("chatbot_eligibility_queue", JSON.stringify(rest));
|
|
} else {
|
|
sessionStorage.removeItem("chatbot_eligibility_queue");
|
|
}
|
|
toast({ title: `${rest.length + 1} patient${rest.length > 0 ? "s" : ""} remaining`, description: `Starting next: ${next!.memberId}` });
|
|
setTimeout(() => {
|
|
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId: next!.memberId, dob: next!.dob, autoCheck: next!.autoCheck }));
|
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
|
}, 1500);
|
|
} catch {}
|
|
};
|
|
|
|
// Redirect from schedule page "Check Eligibility": prefill patient + optionally auto-trigger or scroll
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const appointmentId = params.get("appointmentId");
|
|
if (!appointmentId) return;
|
|
|
|
const id = Number(appointmentId);
|
|
if (Number.isNaN(id) || id <= 0) return;
|
|
|
|
const autoCheck = params.get("autoCheck") as typeof pendingAutoCheck.current;
|
|
const scrollTo = params.get("scrollTo");
|
|
|
|
const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "united-sco", "tufts-sco", "cca"] as const;
|
|
if (autoCheck && (knownAutoChecks as readonly string[]).includes(autoCheck)) {
|
|
pendingAutoCheck.current = autoCheck;
|
|
} else if (scrollTo) {
|
|
pendingScrollTo.current = scrollTo;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
try {
|
|
const res = await apiRequest("GET", `/api/appointments/${id}/patient`);
|
|
if (!res.ok) return;
|
|
|
|
const data = await res.json();
|
|
const patient = data?.patient ?? data;
|
|
|
|
if (!cancelled && patient) {
|
|
setSelectedPatient(patient as Patient);
|
|
clearUrlParams(["appointmentId", "action", "autoCheck", "scrollTo"]);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch patient from appointment", err);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [location]);
|
|
|
|
// Scroll to highlighted button after patient is loaded from schedule page (other insurance)
|
|
useEffect(() => {
|
|
if (!selectedPatient || !pendingScrollTo.current) return;
|
|
const target = pendingScrollTo.current;
|
|
pendingScrollTo.current = null;
|
|
|
|
setTimeout(() => {
|
|
const el = document.getElementById(`eligibility-${target}`);
|
|
if (!el) return;
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
el.classList.add("ring-2", "ring-blue-500", "ring-offset-2", "transition-all");
|
|
setTimeout(() => {
|
|
el.classList.remove("ring-2", "ring-blue-500", "ring-offset-2", "transition-all");
|
|
}, 2500);
|
|
}, 300);
|
|
}, [selectedPatient]);
|
|
|
|
return (
|
|
<div>
|
|
<SeleniumTaskBanner
|
|
status={status}
|
|
message={message}
|
|
show={show}
|
|
onClear={() => dispatch(clearTaskStatus("eligibilityCheck"))}
|
|
/>
|
|
|
|
<div className="container mx-auto space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">
|
|
Insurance Eligibility
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
Check insurance eligibility.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Insurance Eligibility Check Form */}
|
|
<Card className="mb-6">
|
|
<CardContent>
|
|
<div className="grid grid-cols-4 md:grid-cols-4 gap-4 mb-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="memberId">Member ID</Label>
|
|
<Input
|
|
id="memberId"
|
|
placeholder="Enter member ID"
|
|
value={memberId}
|
|
onChange={(e) => setMemberId(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<DateInput
|
|
label="Date of Birth"
|
|
value={dateOfBirth}
|
|
onChange={setDateOfBirth}
|
|
disableFuture
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="firstName">First Name <span className="text-muted-foreground font-normal">(Optional)</span></Label>
|
|
<Input
|
|
id="firstName"
|
|
placeholder="Enter first name"
|
|
value={firstName}
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="lastName">Last Name <span className="text-muted-foreground font-normal">(Optional)</span></Label>
|
|
<Input
|
|
id="lastName"
|
|
placeholder="Enter last name"
|
|
value={lastName}
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col-2 gap-4">
|
|
<Button
|
|
onClick={() => handleMHEligibilityButton()}
|
|
className="w-full"
|
|
disabled={isCheckingEligibilityStatus}
|
|
>
|
|
{isCheckingEligibilityStatus ? (
|
|
<>
|
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
|
Processing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
MH Eligibility
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => handleMHEligibilityAppointmentButton()}
|
|
className="w-full"
|
|
disabled={isCheckingEligibilityAppointment}
|
|
>
|
|
{isCheckingEligibilityAppointment ? (
|
|
<>
|
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
|
Processing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
MH Eligibility&Appointment
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => handleMHEligibilityClaimsPreAuthButton()}
|
|
className="w-full"
|
|
disabled={isCheckingEligibilityClaimsPreAuth}
|
|
>
|
|
{isCheckingEligibilityClaimsPreAuth ? (
|
|
<>
|
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
|
Processing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
MH Eligibility&Claims/PreAuth
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-col-2 gap-4 mt-4">
|
|
<Button
|
|
id="eligibility-mh-history"
|
|
className="w-full"
|
|
disabled={isCheckingEligibilityHistory}
|
|
onClick={() => handleMHEligibilityHistoryButton()}
|
|
>
|
|
{isCheckingEligibilityHistory ? (
|
|
<>
|
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
|
Processing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
MH Eligibility & History
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
id="eligibility-cmsp"
|
|
className="w-full"
|
|
disabled={isCheckingCMSP}
|
|
onClick={() => handleCMSPButton()}
|
|
>
|
|
{isCheckingCMSP ? (
|
|
<>
|
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
|
Processing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
CMSP Eligibility & History & Remaining
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* TEMP PROVIDER BUTTONS */}
|
|
<div id="eligibility-other-providers" className="space-y-4 mt-6">
|
|
<h3 className="text-sm font-medium text-muted-foreground">
|
|
Other provider checks
|
|
</h3>
|
|
|
|
{/* Row 1 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
|
<DdmaEligibilityButton
|
|
memberId={memberId}
|
|
dateOfBirth={dateOfBirth}
|
|
firstName={firstName}
|
|
lastName={lastName}
|
|
isFormIncomplete={isFormIncomplete}
|
|
autoTrigger={triggerTarget === "ddma"}
|
|
onAutoTriggered={() => setTriggerTarget(null)}
|
|
onPdfReady={async (pdfId, fallbackFilename) => {
|
|
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
|
void tryAppointmentFromChatbot();
|
|
if (!claimed) {
|
|
setPreviewPdfId(pdfId);
|
|
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`);
|
|
setPreviewOpen(true);
|
|
}
|
|
processNextInQueue();
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
|
<DeltaInsEligibilityButton
|
|
memberId={memberId}
|
|
dateOfBirth={dateOfBirth}
|
|
firstName={firstName}
|
|
lastName={lastName}
|
|
isFormIncomplete={isFormIncomplete}
|
|
autoTrigger={triggerTarget === "delta-ins"}
|
|
onAutoTriggered={() => setTriggerTarget(null)}
|
|
onPdfReady={async (pdfId, fallbackFilename) => {
|
|
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
|
void tryAppointmentFromChatbot();
|
|
if (!claimed) {
|
|
setPreviewPdfId(pdfId);
|
|
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`);
|
|
setPreviewOpen(true);
|
|
}
|
|
processNextInQueue();
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
|
<BcbsMaEligibilityButton
|
|
memberId={memberId}
|
|
dateOfBirth={dateOfBirth}
|
|
firstName={firstName}
|
|
lastName={lastName}
|
|
isFormIncomplete={isFormIncomplete}
|
|
onPdfReady={async (pdfId, fallbackFilename) => {
|
|
void tryAppointmentFromChatbot();
|
|
setPreviewPdfId(pdfId);
|
|
setPreviewFallbackFilename(
|
|
fallbackFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`,
|
|
);
|
|
setPreviewOpen(true);
|
|
processNextInQueue();
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
|
<TuftsSCOEligibilityButton
|
|
memberId={memberId}
|
|
dateOfBirth={dateOfBirth}
|
|
firstName={firstName}
|
|
lastName={lastName}
|
|
isFormIncomplete={isFormIncomplete}
|
|
autoTrigger={triggerTarget === "tufts-sco"}
|
|
onAutoTriggered={() => setTriggerTarget(null)}
|
|
onPdfReady={async (pdfId, fallbackFilename) => {
|
|
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
|
void tryAppointmentFromChatbot();
|
|
if (!claimed) {
|
|
setPreviewPdfId(pdfId);
|
|
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`);
|
|
setPreviewOpen(true);
|
|
}
|
|
processNextInQueue();
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
|
<UnitedSCOEligibilityButton
|
|
memberId={memberId}
|
|
dateOfBirth={dateOfBirth}
|
|
firstName={firstName}
|
|
lastName={lastName}
|
|
isFormIncomplete={isFormIncomplete}
|
|
autoTrigger={triggerTarget === "united-sco"}
|
|
onAutoTriggered={() => setTriggerTarget(null)}
|
|
onPdfReady={async (pdfId, fallbackFilename) => {
|
|
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
|
void tryAppointmentFromChatbot();
|
|
if (!claimed) {
|
|
setPreviewPdfId(pdfId);
|
|
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
|
|
setPreviewOpen(true);
|
|
}
|
|
processNextInQueue();
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
|
<CCAEligibilityButton
|
|
memberId={memberId}
|
|
dateOfBirth={dateOfBirth}
|
|
firstName={firstName}
|
|
lastName={lastName}
|
|
isFormIncomplete={isFormIncomplete}
|
|
autoTrigger={triggerTarget === "cca"}
|
|
onAutoTriggered={() => setTriggerTarget(null)}
|
|
onPdfReady={async (pdfId, fallbackFilename) => {
|
|
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
|
void tryAppointmentFromChatbot();
|
|
if (!claimed) {
|
|
setPreviewPdfId(pdfId);
|
|
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_cca_${memberId}.pdf`);
|
|
setPreviewOpen(true);
|
|
}
|
|
processNextInQueue();
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
disabled={isFormIncomplete}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
United AAPR
|
|
</Button>
|
|
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
disabled={isFormIncomplete}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Aetna
|
|
</Button>
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
disabled={isFormIncomplete}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Altus
|
|
</Button>
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
disabled={isFormIncomplete}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Metlife Dental
|
|
</Button>
|
|
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
disabled={isFormIncomplete}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Cigna
|
|
</Button>
|
|
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
disabled={isFormIncomplete}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Delta WA
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Row 5 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
disabled={isFormIncomplete}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Delta IL
|
|
</Button>
|
|
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
disabled={isFormIncomplete}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Others
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* AI Call Insurance */}
|
|
<Card>
|
|
<CardHeader
|
|
className="cursor-pointer select-none"
|
|
onClick={() => setAiCallOpen((o) => !o)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-9 w-9 rounded-lg bg-violet-100 flex items-center justify-center flex-shrink-0">
|
|
<Bot className="h-5 w-5 text-violet-600" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-base">AI Call Insurance/Transportation</CardTitle>
|
|
<CardDescription className="mt-0.5">
|
|
Use AI to call the insurance company and check eligibility automatically
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
{aiCallOpen
|
|
? <ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
: <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
|
</div>
|
|
</CardHeader>
|
|
|
|
{aiCallOpen && (
|
|
<CardContent className="space-y-5 pt-0">
|
|
{/* Patient context banner */}
|
|
{selectedPatient && (
|
|
<div className="flex items-center gap-2 text-sm bg-violet-50 border border-violet-200 rounded-md px-3 py-2">
|
|
<Bot className="h-4 w-4 text-violet-500 flex-shrink-0" />
|
|
<span>
|
|
Selected patient:{" "}
|
|
<span className="font-medium">
|
|
{selectedPatient.firstName} {selectedPatient.lastName}
|
|
</span>
|
|
{selectedPatient.insuranceId && (
|
|
<> — Member ID: <span className="font-medium">{selectedPatient.insuranceId}</span></>
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label>Insurance Company</Label>
|
|
{insuranceContacts.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground italic py-2">
|
|
No insurance contacts saved.{" "}
|
|
<a href="/settings/insurancecontact" className="underline text-violet-600">
|
|
Add one in Settings → Insurance Contact
|
|
</a>
|
|
</p>
|
|
) : (
|
|
<Select
|
|
value={aiSelectedContactId}
|
|
onValueChange={setAiSelectedContactId}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select insurance company…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{insuranceContacts.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Phone Number</Label>
|
|
<Input
|
|
readOnly
|
|
value={selectedInsuranceContact?.phoneNumber ?? ""}
|
|
placeholder="Auto-filled from insurance contact"
|
|
className="bg-gray-50 text-gray-600 cursor-default"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor="ai-call-notes">
|
|
Additional Notes for AI{" "}
|
|
<span className="text-muted-foreground font-normal">(Optional)</span>
|
|
</Label>
|
|
<Input
|
|
id="ai-call-notes"
|
|
placeholder="e.g. Ask about dental benefits and annual maximum"
|
|
value={aiCallNotes}
|
|
onChange={(e) => setAiCallNotes(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
disabled
|
|
className="gap-2 bg-violet-600 hover:bg-violet-700 text-white opacity-60 cursor-not-allowed"
|
|
>
|
|
<PhoneCall className="h-4 w-4" />
|
|
Start AI Call
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground italic">
|
|
{selectedInsuranceContact
|
|
? `Will call ${selectedInsuranceContact.name}${selectedInsuranceContact.phoneNumber ? ` at ${selectedInsuranceContact.phoneNumber}` : ""} — AI calling logic coming soon`
|
|
: "Select an insurance company to begin — AI calling logic coming soon"}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Patients Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Patient Records</CardTitle>
|
|
<CardDescription>
|
|
Select Patients and Check Their Eligibility
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<PatientTable
|
|
allowView={true}
|
|
allowDelete={true}
|
|
allowCheckbox={true}
|
|
allowEdit={true}
|
|
onSelectPatient={setSelectedPatient}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Pdf preview modal */}
|
|
<PdfPreviewModal
|
|
open={previewOpen}
|
|
onClose={() => {
|
|
setPreviewOpen(false);
|
|
setPreviewPdfId(null);
|
|
setPreviewFallbackFilename(null);
|
|
}}
|
|
pdfId={previewPdfId ?? undefined}
|
|
fallbackFilename={previewFallbackFilename ?? undefined}
|
|
autoDownload
|
|
/>
|
|
|
|
{/* 4-panel modal for CMSP — eligibility, member details, history, accumulator */}
|
|
<DualPdfPreviewModal
|
|
open={cmspPreviewOpen}
|
|
onClose={() => {
|
|
setCmspPreviewOpen(false);
|
|
setCmspEligibilityPdfId(null);
|
|
setCmspEligibilityFilename(null);
|
|
setCmspMemberDetailsPdfId(null);
|
|
setCmspMemberDetailsFilename(null);
|
|
setCmspHistoryPdfId(null);
|
|
setCmspHistoryFilename(null);
|
|
setCmspAccumulatorPdfId(null);
|
|
setCmspAccumulatorFilename(null);
|
|
}}
|
|
title="CMSP Eligibility, History & Remaining"
|
|
panels={[
|
|
{
|
|
pdfId: cmspEligibilityPdfId,
|
|
fallbackFilename: cmspEligibilityFilename,
|
|
label: "Eligibility",
|
|
autoDownload: true,
|
|
},
|
|
{
|
|
pdfId: cmspMemberDetailsPdfId,
|
|
fallbackFilename: cmspMemberDetailsFilename,
|
|
label: "Member Details",
|
|
},
|
|
{
|
|
pdfId: cmspHistoryPdfId,
|
|
fallbackFilename: cmspHistoryFilename,
|
|
label: "Service History",
|
|
},
|
|
{
|
|
pdfId: cmspAccumulatorPdfId,
|
|
fallbackFilename: cmspAccumulatorFilename,
|
|
label: "Accumulator (Remaining)",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* 3-panel modal for MH Eligibility & History */}
|
|
<DualPdfPreviewModal
|
|
open={dualPreviewOpen}
|
|
onClose={() => {
|
|
setDualPreviewOpen(false);
|
|
setDualEligibilityPdfId(null);
|
|
setDualEligibilityFilename(null);
|
|
setDualMemberDetailsPdfId(null);
|
|
setDualMemberDetailsFilename(null);
|
|
setDualHistoryPdfId(null);
|
|
setDualHistoryFilename(null);
|
|
}}
|
|
title="MH Eligibility & Service History"
|
|
panels={[
|
|
{
|
|
pdfId: dualEligibilityPdfId,
|
|
fallbackFilename: dualEligibilityFilename,
|
|
label: "Eligibility",
|
|
autoDownload: true,
|
|
},
|
|
{
|
|
pdfId: dualMemberDetailsPdfId,
|
|
fallbackFilename: dualMemberDetailsFilename,
|
|
label: "Member Details",
|
|
},
|
|
{
|
|
pdfId: dualHistoryPdfId,
|
|
fallbackFilename: dualHistoryFilename,
|
|
label: "Service History",
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|