- 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.
1722 lines
78 KiB
TypeScript
1722 lines
78 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import {
|
|
Bot,
|
|
X,
|
|
ChevronRight,
|
|
Stethoscope,
|
|
Calendar,
|
|
FileText,
|
|
MessageSquare,
|
|
Send,
|
|
Loader2,
|
|
RotateCcw,
|
|
Paperclip,
|
|
Image as ImageIcon,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useLocation } from "wouter";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import { setChatbotPendingFiles } from "@/lib/chatbotFileStore";
|
|
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
|
|
|
|
type Step =
|
|
| "menu"
|
|
| "eligibility-input"
|
|
| "eligibility-confirm"
|
|
| "ai-loading"
|
|
| "patient-found"
|
|
| "eligibility-id-ready"
|
|
| "batch-eligibility-ready"
|
|
| "check-and-claim-ready"
|
|
| "need-insurance-clarification"
|
|
| "need-appointment-selection"
|
|
| "need-cdt-clarification"
|
|
| "claim-ready"
|
|
| "batch-claim-ready"
|
|
| "multi-claim-ready"
|
|
| "batch-check-and-claim-ready"
|
|
| "preauth-ready";
|
|
|
|
interface Message {
|
|
id: number;
|
|
role: "bot" | "user";
|
|
text: string;
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
interface PatientResult {
|
|
id: number;
|
|
firstName: string;
|
|
lastName: string;
|
|
insuranceId: string | null;
|
|
insuranceProvider: string | null;
|
|
dateOfBirth: string | null;
|
|
}
|
|
|
|
interface EligibilityData {
|
|
memberId: string;
|
|
dob: string;
|
|
dobISO: string;
|
|
}
|
|
|
|
interface CheckAndClaimData {
|
|
patient: PatientResult | null;
|
|
memberId: string;
|
|
dob: string; // ISO YYYY-MM-DD
|
|
siteKey: string;
|
|
autoCheck: string;
|
|
matchedCodes: { code: string; description: string }[];
|
|
serviceDate?: string | null;
|
|
renderingProvider?: string | null;
|
|
}
|
|
|
|
let msgCounter = 0;
|
|
function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message {
|
|
return { id: ++msgCounter, role, text, isLoading };
|
|
}
|
|
|
|
// eligibilityIdData.dob is stored/sent as ISO ("YYYY-MM-DD") everywhere downstream (patient
|
|
// sync, sessionStorage prefill, matching) — these only convert for display/editing.
|
|
function isoToMDY(iso: string): string {
|
|
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
if (!m) return iso;
|
|
return `${m[2]}/${m[3]}/${m[1]}`;
|
|
}
|
|
|
|
function mdyToISO(mdy: string): string {
|
|
const m = mdy.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
if (!m) return mdy;
|
|
return `${m[3]}-${m[1]!.padStart(2, "0")}-${m[2]!.padStart(2, "0")}`;
|
|
}
|
|
|
|
const INSURANCE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
|
{ value: "mh", label: "MassHealth" },
|
|
{ value: "cmsp", label: "CMSP" },
|
|
{ value: "ddma", label: "DDMA" },
|
|
{ value: "delta-ins", label: "Delta Dental" },
|
|
{ value: "united-sco", label: "United SCO" },
|
|
{ value: "tufts-sco", label: "Tufts SCO" },
|
|
{ value: "cca", label: "CCA" },
|
|
];
|
|
|
|
function getAutoCheck(dobISO: string): "mh" | "cmsp" {
|
|
const [y, m, d] = dobISO.split("-").map(Number);
|
|
const today = new Date();
|
|
let age = today.getFullYear() - (y ?? 0);
|
|
const monthDiff = today.getMonth() + 1 - (m ?? 0);
|
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < (d ?? 0))) age--;
|
|
return age >= 21 ? "mh" : "cmsp";
|
|
}
|
|
|
|
function parseEligibilityInput(
|
|
raw: string
|
|
): { memberId: string; display: string; iso: string } | null {
|
|
const dateMatch = raw.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\b/);
|
|
if (!dateMatch) return null;
|
|
const m = dateMatch[1];
|
|
const d = dateMatch[2];
|
|
const y = dateMatch[3];
|
|
if (!m || !d || !y) return null;
|
|
const month = parseInt(m, 10);
|
|
const day = parseInt(d, 10);
|
|
const year = parseInt(y, 10);
|
|
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900) return null;
|
|
|
|
const withoutDate = raw.replace(dateMatch[0], "");
|
|
const memberId = (withoutDate.match(/[a-zA-Z0-9]/g) ?? []).join("");
|
|
if (!memberId) return null;
|
|
|
|
return {
|
|
memberId,
|
|
display: `${m.padStart(2, "0")}/${d.padStart(2, "0")}/${y}`,
|
|
iso: `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`,
|
|
};
|
|
}
|
|
|
|
const CHAT_STORAGE_KEY = "chatbot_messages";
|
|
const CHATBOT_JOB_TS_KEY = "chatbot_job_started_at";
|
|
|
|
function markJobStarted() {
|
|
try { sessionStorage.setItem(CHATBOT_JOB_TS_KEY, String(Date.now())); } catch {}
|
|
}
|
|
|
|
function shouldAutoReset(): boolean {
|
|
try {
|
|
const ts = sessionStorage.getItem(CHATBOT_JOB_TS_KEY);
|
|
if (!ts) return false;
|
|
return Date.now() - Number(ts) > 60_000;
|
|
} catch { return false; }
|
|
}
|
|
|
|
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?")];
|
|
}
|
|
|
|
const WELCOME = [makeMsg("bot", "Hi! What can I help you with today?")];
|
|
|
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
function saveChatHistoryToServer(msgs: Message[]) {
|
|
if (saveTimer) clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => {
|
|
const saveable = msgs.filter((m) => !m.isLoading);
|
|
fetch("/api/ai/chat-history", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${localStorage.getItem("token") ?? ""}` },
|
|
body: JSON.stringify({ messages: saveable }),
|
|
}).catch(() => {});
|
|
}, 500);
|
|
}
|
|
|
|
function clearChatHistoryOnServer() {
|
|
fetch("/api/ai/chat-history", {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${localStorage.getItem("token") ?? ""}` },
|
|
}).catch(() => {});
|
|
}
|
|
|
|
export function ChatbotButton() {
|
|
const [open, setOpen] = useState(false);
|
|
const [step, setStep] = useState<Step>("menu");
|
|
const [messages, setMessages] = useState<Message[]>(loadSavedMessages);
|
|
const [pasteInput, setPasteInput] = useState("");
|
|
const [parseError, setParseError] = useState("");
|
|
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
|
|
const [freeTextInput, setFreeTextInput] = useState("");
|
|
const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
|
|
// Snapshot of insuranceId/dateOfBirth as originally fetched, so we can tell whether the
|
|
// user hand-edited them in the patient-found card and only PUT to the DB when they did.
|
|
const originalPatientResultRef = useRef<{ insuranceId: string | null; dateOfBirth: string | null } | null>(null);
|
|
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; appointmentDate?: string | null } | null>(null);
|
|
const [batchEligibilityData, setBatchEligibilityData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[] | 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 [apptSelectionData, setApptSelectionData] = useState<{
|
|
patient: PatientResult;
|
|
siteKey: string;
|
|
matchedCodes: { code: string; description: string }[];
|
|
options: { label: string; appointmentId: number; serviceDate: string }[];
|
|
} | null>(null);
|
|
const [cdtClarificationData, setCdtClarificationData] = useState<{
|
|
unknownPhrases: string[];
|
|
codeInputs: Record<string, string>;
|
|
originalMessage: string;
|
|
} | null>(null);
|
|
const [claimReadyData, setClaimReadyData] = useState<{
|
|
patient: PatientResult | null;
|
|
matchedCodes: { code: string; description: string }[];
|
|
siteKey: string;
|
|
serviceDate: string;
|
|
appointmentId: number | null;
|
|
renderingProvider: string | null;
|
|
} | null>(null);
|
|
const [batchCheckAndClaimData, setBatchCheckAndClaimData] = useState<{
|
|
queue: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[];
|
|
matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[];
|
|
renderingProvider: string | null;
|
|
} | null>(null);
|
|
const [batchClaimData, setBatchClaimData] = useState<{
|
|
queue: { patient: PatientResult; siteKey: string; serviceDate: string | null; appointmentId: number | null }[];
|
|
matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[];
|
|
renderingProvider: string | null;
|
|
} | null>(null);
|
|
const [preauthReadyData, setPreauthReadyData] = useState<{
|
|
patient: PatientResult | null;
|
|
matchedCodes: { code: string; description: string; toothNumber?: string }[];
|
|
siteKey: string;
|
|
serviceDate: string;
|
|
renderingProvider: string | null;
|
|
} | null>(null);
|
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
|
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
|
|
const [, setLocation] = useLocation();
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
|
const freeTextRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
// Set by the Copy Agent page's "AI Detect & Check Eligibility" flow when the screenshots were
|
|
// saved under a placeholder folder name; renamed to the real patient name once the user confirms.
|
|
const pendingFolderRenameRef = useRef<{ patientId: number; patientName: string } | null>(null);
|
|
|
|
// Load chat history from server on first mount (server is source of truth)
|
|
const serverLoaded = useRef(false);
|
|
useEffect(() => {
|
|
if (serverLoaded.current) return;
|
|
serverLoaded.current = true;
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
fetch("/api/ai/chat-history", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (Array.isArray(data.messages) && data.messages.length > 0) {
|
|
setMessages(data.messages);
|
|
try { sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(data.messages)); } catch {}
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages, step]);
|
|
|
|
// Opened externally by the Copy Agent page after it saves screenshots and runs AI
|
|
// Member ID/DOB extraction — lands the chatbot directly on the confirm step.
|
|
useEffect(() => {
|
|
function handleOpenEligibilityIdReady(e: Event) {
|
|
const detail = (e as CustomEvent).detail as {
|
|
memberId: string | null;
|
|
dob: string | null;
|
|
autoCheck: string;
|
|
patient: PatientResult | null;
|
|
patientId?: number;
|
|
pendingFolderPatientName?: string;
|
|
};
|
|
|
|
if (detail.patientId && detail.pendingFolderPatientName) {
|
|
pendingFolderRenameRef.current = {
|
|
patientId: detail.patientId,
|
|
patientName: detail.pendingFolderPatientName,
|
|
};
|
|
}
|
|
|
|
if (shouldAutoReset()) resetStep();
|
|
setOpen(true);
|
|
|
|
if (detail.memberId && detail.dob) {
|
|
setEligibilityIdData({
|
|
memberId: detail.memberId,
|
|
dob: detail.dob,
|
|
siteKey: "",
|
|
autoCheck: detail.autoCheck || "mh",
|
|
patient: detail.patient,
|
|
appointmentDate: null,
|
|
});
|
|
addMsg(
|
|
"bot",
|
|
`I read this from your screenshots — ready to check ${detail.autoCheck === "mh" ? "MassHealth" : "eligibility"} for ${
|
|
detail.patient ? `${detail.patient.firstName} ${detail.patient.lastName}` : detail.memberId
|
|
}. Confirm to proceed.`
|
|
);
|
|
setStep("eligibility-id-ready");
|
|
} else {
|
|
setPasteInput([detail.memberId, detail.dob].filter(Boolean).join(" "));
|
|
addMsg(
|
|
"bot",
|
|
"I saved your screenshots but couldn't fully read the Member ID/DOB. Please review and complete the fields below:"
|
|
);
|
|
setStep("eligibility-input");
|
|
}
|
|
}
|
|
|
|
window.addEventListener("chatbot:open-eligibility-id-ready", handleOpenEligibilityIdReady);
|
|
return () => window.removeEventListener("chatbot:open-eligibility-id-ready", handleOpenEligibilityIdReady);
|
|
}, []);
|
|
|
|
// Persist messages to sessionStorage AND server
|
|
useEffect(() => {
|
|
try {
|
|
const saveable = messages.filter((m) => !m.isLoading);
|
|
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable));
|
|
saveChatHistoryToServer(messages);
|
|
} catch {}
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
if (step === "eligibility-input") {
|
|
setTimeout(() => pasteRef.current?.focus(), 50);
|
|
}
|
|
if (step === "menu") {
|
|
setTimeout(() => freeTextRef.current?.focus(), 50);
|
|
}
|
|
}, [step]);
|
|
|
|
const addMsg = (role: "bot" | "user", text: string, isLoading = false) =>
|
|
setMessages((prev) => [...prev, makeMsg(role, text, isLoading)]);
|
|
|
|
const replaceLastMsg = (text: string) =>
|
|
setMessages((prev) => {
|
|
const next = [...prev];
|
|
const last = next[next.length - 1];
|
|
if (last) next[next.length - 1] = { ...last, text, isLoading: false };
|
|
return next;
|
|
});
|
|
|
|
// Resets step/data only — keeps message history
|
|
const resetStep = () => {
|
|
setStep("menu");
|
|
setPasteInput("");
|
|
setParseError("");
|
|
setEligibilityData(null);
|
|
setFreeTextInput("");
|
|
setPatientResult(null);
|
|
setEligibilityIdData(null);
|
|
setBatchEligibilityData(null);
|
|
setCheckAndClaimData(null);
|
|
setClarificationData(null);
|
|
setApptSelectionData(null);
|
|
setCdtClarificationData(null);
|
|
setClaimReadyData(null);
|
|
setBatchClaimData(null);
|
|
setBatchCheckAndClaimData(null);
|
|
setPreauthReadyData(null);
|
|
pendingFolderRenameRef.current = null;
|
|
};
|
|
|
|
// Full reset including message history and stored session
|
|
const reset = () => {
|
|
resetStep();
|
|
setPendingFiles([]);
|
|
const fresh = [makeMsg("bot", "Hi! What can I help you with today?")];
|
|
try {
|
|
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh));
|
|
} catch {}
|
|
clearChatHistoryOnServer();
|
|
setMessages(fresh);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setOpen(false);
|
|
resetStep();
|
|
};
|
|
|
|
const handleOptionSelect = (option: "eligibility" | "schedule" | "claims") => {
|
|
if (option === "schedule") {
|
|
addMsg("user", "Schedule an appointment");
|
|
addMsg("bot", "Opening the appointments page...");
|
|
setTimeout(() => { setLocation("/appointments"); setOpen(false); resetStep(); }, 600);
|
|
} else if (option === "claims") {
|
|
addMsg("user", "View claims");
|
|
addMsg("bot", "Opening the claims page...");
|
|
setTimeout(() => { setLocation("/claims"); setOpen(false); resetStep(); }, 600);
|
|
} else if (option === "eligibility") {
|
|
addMsg("user", "Check Eligibility");
|
|
addMsg("bot", "Please enter the patient's Member ID and Date of Birth:");
|
|
setStep("eligibility-input");
|
|
}
|
|
};
|
|
|
|
const handleEligibilitySubmit = () => {
|
|
const parsed = parseEligibilityInput(pasteInput);
|
|
if (!parsed) {
|
|
setParseError("Couldn't find both a Member ID and a date. Try: 123456789 01/15/1985");
|
|
return;
|
|
}
|
|
setParseError("");
|
|
const data: EligibilityData = { memberId: parsed.memberId, dob: parsed.display, dobISO: parsed.iso };
|
|
setEligibilityData(data);
|
|
addMsg("user", pasteInput.trim());
|
|
addMsg("bot", `Ready to check MassHealth eligibility for:\n• Member ID: ${parsed.memberId}\n• Date of Birth: ${parsed.display}\n\nShall I proceed?`);
|
|
setStep("eligibility-confirm");
|
|
};
|
|
|
|
const handleConfirm = () => {
|
|
if (!eligibilityData) return;
|
|
addMsg("user", "Yes, check now");
|
|
addMsg("bot", "Opening the eligibility check page with this patient...");
|
|
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({
|
|
memberId: eligibilityData.memberId,
|
|
dob: eligibilityData.dobISO,
|
|
autoCheck: getAutoCheck(eligibilityData.dobISO),
|
|
}));
|
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
|
|
};
|
|
|
|
// If the user hand-edited Member ID / DOB on the patient-found card, save the correction
|
|
// back to the patient record so it doesn't revert to the stale value next time.
|
|
const persistPatientEditsIfChanged = async (patient: PatientResult) => {
|
|
const original = originalPatientResultRef.current;
|
|
if (!original) return;
|
|
const changed =
|
|
patient.insuranceId !== original.insuranceId || patient.dateOfBirth !== original.dateOfBirth;
|
|
if (!changed) return;
|
|
try {
|
|
await apiRequest("PUT", `/api/patients/${patient.id}`, {
|
|
insuranceId: patient.insuranceId,
|
|
dateOfBirth: patient.dateOfBirth,
|
|
});
|
|
originalPatientResultRef.current = { insuranceId: patient.insuranceId, dateOfBirth: patient.dateOfBirth };
|
|
} catch {
|
|
// Non-fatal — the edited values are still used for this eligibility check below.
|
|
}
|
|
};
|
|
|
|
const handleEligibilityFromPatient = async () => {
|
|
if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return;
|
|
addMsg("user", "Check eligibility now");
|
|
addMsg("bot", "Opening the eligibility check page...");
|
|
await persistPatientEditsIfChanged(patientResult);
|
|
prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth));
|
|
};
|
|
|
|
const handleEligibilityAndAppointmentFromPatient = async () => {
|
|
if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return;
|
|
addMsg("user", "Check eligibility & add to schedule (today)");
|
|
addMsg("bot", "Running eligibility check — will add to today's schedule after...");
|
|
await persistPatientEditsIfChanged(patientResult);
|
|
// Create the appointment only after eligibility is confirmed, not before — the eligibility
|
|
// page's tryAppointmentFromChatbot picks this up once the check running below completes.
|
|
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: patientResult.insuranceId, date: null }));
|
|
prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth));
|
|
};
|
|
|
|
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
|
|
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
|
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
|
markJobStarted();
|
|
if (pendingFolderRenameRef.current) {
|
|
const { patientId, patientName } = pendingFolderRenameRef.current;
|
|
pendingFolderRenameRef.current = null;
|
|
apiRequest("PATCH", `/api/claims/patient-folder/${patientId}/confirm-name`, { patientName }).catch(() => {});
|
|
}
|
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
|
|
};
|
|
|
|
// If the user hand-edited the Member ID/DOB on the "eligibility-id-ready" card (e.g. fixing a
|
|
// misread digit), push the correction to the patient record too — otherwise the real eligibility
|
|
// check (which matches by insuranceId+dob) would create a second, disconnected patient instead
|
|
// of updating this one. Surface a failure in the chat rather than swallowing it, since a silent
|
|
// failure here means the check proceeds against a stale record and silently creates a duplicate.
|
|
const syncEditedPatientFields = async (memberId: string, dob: string, patientId?: number): Promise<boolean> => {
|
|
if (!patientId) return true;
|
|
try {
|
|
await apiRequest("PUT", `/api/patients/${patientId}`, { insuranceId: memberId, dateOfBirth: dob });
|
|
return true;
|
|
} catch (err: any) {
|
|
console.error("Failed to sync edited Member ID/DOB to patient record:", err);
|
|
addMsg(
|
|
"bot",
|
|
`⚠️ Couldn't save the edited Member ID/DOB to the patient record (${err?.message ?? "unknown error"}). The eligibility check may create a duplicate patient — check the Patients page after.`
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleEligibilityIdRun = async () => {
|
|
if (!eligibilityIdData) return;
|
|
addMsg("user", "Check eligibility now");
|
|
addMsg("bot", "Opening the eligibility check page...");
|
|
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
|
|
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
|
|
};
|
|
|
|
const handleBatchEligibilityRun = () => {
|
|
if (!batchEligibilityData || batchEligibilityData.length === 0) return;
|
|
addMsg("user", `Check all ${batchEligibilityData.length} patients`);
|
|
addMsg("bot", `Checking ${batchEligibilityData.length} patients one by one...`);
|
|
const [first, ...rest] = batchEligibilityData;
|
|
if (rest.length > 0) {
|
|
sessionStorage.setItem("chatbot_eligibility_queue", JSON.stringify(rest));
|
|
}
|
|
prefillAndNavigate(first!.memberId, first!.dob, first!.autoCheck);
|
|
};
|
|
|
|
const handleBatchEligibilityAndAppointment = () => {
|
|
if (!batchEligibilityData || batchEligibilityData.length === 0) return;
|
|
addMsg("user", `Check all & appointment today (${batchEligibilityData.length} patients)`);
|
|
addMsg("bot", `Checking ${batchEligibilityData.length} patients — appointments will be created after each check...`);
|
|
sessionStorage.setItem("chatbot_batch_appt_after_eligibility", "true");
|
|
const [first, ...rest] = batchEligibilityData;
|
|
if (rest.length > 0) {
|
|
sessionStorage.setItem("chatbot_eligibility_queue", JSON.stringify(rest));
|
|
}
|
|
prefillAndNavigate(first!.memberId, first!.dob, first!.autoCheck);
|
|
};
|
|
|
|
const handleEligibilityAndAppointment = async (targetDate?: string) => {
|
|
if (!eligibilityIdData) return;
|
|
const dateLabel = targetDate
|
|
? new Date(targetDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
: "today";
|
|
addMsg("user", `Check eligibility & add to schedule (${dateLabel})`);
|
|
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
|
|
addMsg("bot", `Running eligibility check — will add${eligibilityIdData.patient ? "" : " patient and"} to schedule for ${dateLabel} after...`);
|
|
// Create the appointment only after eligibility is confirmed, not before — the eligibility
|
|
// page's tryAppointmentFromChatbot picks this up once the check running below completes.
|
|
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId, date: targetDate ?? null }));
|
|
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
|
|
};
|
|
|
|
const handleCheckAndClaimRun = () => {
|
|
if (!checkAndClaimData) return;
|
|
addMsg("user", "Run check & claim");
|
|
addMsg("bot", "Opening the eligibility check page...");
|
|
// Store claim codes so the eligibility page can offer auto-claim after ACTIVE result
|
|
sessionStorage.setItem(
|
|
"chatbot_claim_codes",
|
|
JSON.stringify({
|
|
codes: checkAndClaimData.matchedCodes,
|
|
siteKey: checkAndClaimData.siteKey,
|
|
patientId: checkAndClaimData.patient?.id ?? null,
|
|
memberId: checkAndClaimData.memberId,
|
|
dob: checkAndClaimData.dob,
|
|
serviceDate: checkAndClaimData.serviceDate ?? null,
|
|
renderingProvider: checkAndClaimData.renderingProvider ?? null,
|
|
})
|
|
);
|
|
setChatbotPendingFiles(pendingFiles);
|
|
prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck);
|
|
};
|
|
|
|
const handleFreeTextSubmit = async () => {
|
|
const text = freeTextInput.trim();
|
|
if (!text || step === "ai-loading") return;
|
|
setFreeTextInput("");
|
|
addMsg("user", text);
|
|
addMsg("bot", "Thinking...", true);
|
|
setStep("ai-loading");
|
|
|
|
try {
|
|
const history = messages
|
|
.filter((m) => !m.isLoading)
|
|
.slice(-15)
|
|
.map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
|
|
const d = new Date();
|
|
const clientDate = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
|
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history, clientDate });
|
|
const data = await res.json();
|
|
|
|
const claimActions = new Set(["claim_only_ready", "check_and_claim_ready", "need_appointment_selection"]);
|
|
const attachmentSuffix =
|
|
pendingFiles.length > 0 && claimActions.has(data.action)
|
|
? ` (📎 ${pendingFiles.length} attachment${pendingFiles.length > 1 ? "s" : ""} will be included)`
|
|
: "";
|
|
replaceLastMsg((data.reply ?? "Sorry, I couldn't process that.") + attachmentSuffix);
|
|
|
|
if (data.action === "navigate" && data.actionData?.url) {
|
|
setTimeout(() => { setLocation(data.actionData.url); setOpen(false); resetStep(); }, 800);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(data.action === "check_eligibility_prefill" || data.action === "show_patient") &&
|
|
data.actionData?.patient
|
|
) {
|
|
setPatientResult(data.actionData.patient);
|
|
originalPatientResultRef.current = {
|
|
insuranceId: data.actionData.patient.insuranceId,
|
|
dateOfBirth: data.actionData.patient.dateOfBirth,
|
|
};
|
|
setStep("patient-found");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "batch_eligibility_ready" && data.actionData?.queue) {
|
|
setBatchEligibilityData(data.actionData.queue);
|
|
setStep("batch-eligibility-ready");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "eligibility_id_ready" && data.actionData) {
|
|
setEligibilityIdData({
|
|
memberId: data.actionData.memberId,
|
|
dob: data.actionData.dob,
|
|
siteKey: data.actionData.siteKey,
|
|
autoCheck: data.actionData.autoCheck,
|
|
patient: data.actionData.patient ?? null,
|
|
appointmentDate: data.actionData.appointmentDate ?? null,
|
|
});
|
|
setStep("eligibility-id-ready");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "check_and_claim_ready" && data.actionData) {
|
|
setCheckAndClaimData({
|
|
patient: data.actionData.patient ?? null,
|
|
memberId: data.actionData.memberId,
|
|
dob: data.actionData.dob,
|
|
siteKey: data.actionData.siteKey,
|
|
autoCheck: data.actionData.autoCheck,
|
|
matchedCodes: data.actionData.matchedCodes ?? [],
|
|
serviceDate: data.actionData.serviceDate ?? null,
|
|
renderingProvider: data.actionData.renderingProvider ?? null,
|
|
});
|
|
setStep("check-and-claim-ready");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "need_insurance_clarification" && data.actionData) {
|
|
setClarificationData({
|
|
memberId: data.actionData.memberId,
|
|
dob: data.actionData.dob,
|
|
patient: data.actionData.patient ?? null,
|
|
procedureNames: data.actionData.procedureNames ?? [],
|
|
options: data.actionData.options ?? [],
|
|
});
|
|
setStep("need-insurance-clarification");
|
|
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 === "need_cdt_clarification" && data.actionData) {
|
|
const phrases: string[] = data.actionData.unknownPhrases ?? [];
|
|
const inputs: Record<string, string> = {};
|
|
for (const p of phrases) inputs[p] = "";
|
|
setCdtClarificationData({ unknownPhrases: phrases, codeInputs: inputs, originalMessage: text });
|
|
setStep("need-cdt-clarification");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "batch_check_and_claim_ready" && data.actionData) {
|
|
setBatchCheckAndClaimData({
|
|
queue: data.actionData.queue ?? [],
|
|
matchedCodes: data.actionData.matchedCodes ?? [],
|
|
renderingProvider: data.actionData.renderingProvider ?? null,
|
|
});
|
|
setStep("batch-check-and-claim-ready");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "batch_claim_ready" && data.actionData) {
|
|
setBatchClaimData({
|
|
queue: data.actionData.queue ?? [],
|
|
matchedCodes: data.actionData.matchedCodes ?? [],
|
|
renderingProvider: data.actionData.renderingProvider ?? null,
|
|
});
|
|
setStep("batch-claim-ready");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "multi_claim_ready" && data.actionData) {
|
|
setBatchClaimData({
|
|
queue: data.actionData.queue ?? [],
|
|
matchedCodes: [],
|
|
renderingProvider: data.actionData.renderingProvider ?? null,
|
|
});
|
|
setStep("multi-claim-ready");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "claim_only_ready" && data.actionData) {
|
|
const { patient, matchedCodes, siteKey, serviceDate, appointmentId, renderingProvider } = data.actionData;
|
|
setClaimReadyData({
|
|
patient: patient ?? null,
|
|
matchedCodes: matchedCodes ?? [],
|
|
siteKey,
|
|
serviceDate,
|
|
appointmentId: appointmentId ?? null,
|
|
renderingProvider: renderingProvider ?? null,
|
|
});
|
|
setStep("claim-ready");
|
|
return;
|
|
}
|
|
|
|
if (data.action === "preauth_ready" && data.actionData) {
|
|
const { patient, matchedCodes, siteKey, serviceDate, renderingProvider } = data.actionData;
|
|
setPreauthReadyData({
|
|
patient: patient ?? null,
|
|
matchedCodes: matchedCodes ?? [],
|
|
siteKey,
|
|
serviceDate,
|
|
renderingProvider: renderingProvider ?? null,
|
|
});
|
|
setStep("preauth-ready");
|
|
return;
|
|
}
|
|
|
|
setStep("menu");
|
|
} catch {
|
|
replaceLastMsg("Sorry, something went wrong. Please try again.");
|
|
setStep("menu");
|
|
}
|
|
};
|
|
|
|
const handleFreeTextKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleFreeTextSubmit();
|
|
}
|
|
};
|
|
|
|
const showFreeTextInput =
|
|
step === "menu" ||
|
|
step === "ai-loading" ||
|
|
step === "patient-found" ||
|
|
step === "eligibility-id-ready" ||
|
|
step === "batch-eligibility-ready" ||
|
|
step === "check-and-claim-ready" ||
|
|
step === "batch-claim-ready" ||
|
|
step === "multi-claim-ready" ||
|
|
step === "batch-check-and-claim-ready" ||
|
|
step === "need-insurance-clarification" ||
|
|
step === "need-appointment-selection";
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="relative h-8 w-8 rounded-full p-0"
|
|
title="Open Assistant"
|
|
onClick={() => { if (shouldAutoReset()) resetStep(); setOpen(true); }}
|
|
>
|
|
<Bot className="h-5 w-5 text-primary" />
|
|
</Button>
|
|
|
|
{open && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 z-40" onClick={handleClose} aria-hidden />
|
|
|
|
{/* Chat panel */}
|
|
<div className="fixed top-16 right-0 z-50 w-80 h-[calc(100vh-4rem)] bg-white border-l shadow-2xl flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 bg-primary text-white shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<Bot className="h-4 w-4" />
|
|
<span className="font-semibold text-sm">Assistant</span>
|
|
</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
|
|
onClick={handleClose}
|
|
className="hover:opacity-70 transition-opacity rounded"
|
|
aria-label="Close"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
{messages.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
className={cn("flex", msg.role === "user" ? "justify-end" : "justify-start")}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-line leading-relaxed",
|
|
msg.role === "user"
|
|
? "bg-primary text-white rounded-tr-sm"
|
|
: "bg-gray-100 text-gray-800 rounded-tl-sm"
|
|
)}
|
|
>
|
|
{msg.isLoading ? (
|
|
<span className="flex items-center gap-1.5">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Thinking...
|
|
</span>
|
|
) : (
|
|
msg.text
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Quick-action buttons (menu step) */}
|
|
{step === "menu" && (
|
|
<div className="grid grid-cols-2 gap-2 pt-1">
|
|
<button
|
|
onClick={() => handleOptionSelect("eligibility")}
|
|
className="flex items-center gap-2 p-3 rounded-xl border border-primary/30 hover:bg-primary/5 text-sm text-left transition-colors"
|
|
>
|
|
<Stethoscope className="h-4 w-4 text-primary shrink-0" />
|
|
<span>Check Eligibility</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleOptionSelect("schedule")}
|
|
className="flex items-center gap-2 p-3 rounded-xl border border-blue-300 hover:bg-blue-50 text-sm text-left transition-colors"
|
|
>
|
|
<Calendar className="h-4 w-4 text-blue-500 shrink-0" />
|
|
<span>Schedule</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleOptionSelect("claims")}
|
|
className="flex items-center gap-2 p-3 rounded-xl border border-orange-300 hover:bg-orange-50 text-sm text-left transition-colors col-span-2"
|
|
>
|
|
<FileText className="h-4 w-4 text-orange-500 shrink-0" />
|
|
<span>Claims</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Eligibility manual input */}
|
|
{step === "eligibility-input" && (
|
|
<div className="space-y-2 bg-gray-50 rounded-xl p-3 border">
|
|
<Label className="text-xs text-gray-600">Paste Member ID and Date of Birth</Label>
|
|
<textarea
|
|
ref={pasteRef}
|
|
rows={3}
|
|
placeholder={"e.g. 123456789 01/15/1985\nor 01/15/1985 123456789"}
|
|
value={pasteInput}
|
|
onChange={(e) => { setPasteInput(e.target.value); setParseError(""); }}
|
|
className={cn(
|
|
"w-full rounded-md border bg-white px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring",
|
|
parseError && "border-red-400"
|
|
)}
|
|
/>
|
|
{parseError && <p className="text-xs text-red-500">{parseError}</p>}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs"
|
|
onClick={handleEligibilitySubmit}
|
|
disabled={!pasteInput.trim()}
|
|
>
|
|
Continue <ChevronRight className="h-3 w-3 ml-1" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Eligibility confirm */}
|
|
{step === "eligibility-confirm" && (
|
|
<div className="flex gap-2">
|
|
<Button size="sm" className="flex-1 h-8 text-xs" onClick={handleConfirm}>
|
|
Yes, Check Now
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 text-xs"
|
|
onClick={() => {
|
|
setStep("eligibility-input");
|
|
setMessages((prev) => prev.slice(0, -2));
|
|
}}
|
|
>
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI-found patient card */}
|
|
{step === "patient-found" && patientResult && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-blue-800">
|
|
{patientResult.firstName} {patientResult.lastName}
|
|
</p>
|
|
{patientResult.insuranceProvider && (
|
|
<p className="text-xs text-blue-600">{patientResult.insuranceProvider}</p>
|
|
)}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-gray-500">Member ID</Label>
|
|
<Input
|
|
value={patientResult.insuranceId ?? ""}
|
|
onChange={(e) =>
|
|
setPatientResult((prev) => (prev ? { ...prev, insuranceId: e.target.value } : prev))
|
|
}
|
|
className="h-7 text-xs bg-white"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-gray-500">Date of Birth (MM/DD/YYYY)</Label>
|
|
<Input
|
|
value={patientResult.dateOfBirth ? isoToMDY(patientResult.dateOfBirth) : ""}
|
|
onChange={(e) =>
|
|
setPatientResult((prev) =>
|
|
prev ? { ...prev, dateOfBirth: mdyToISO(e.target.value) } : prev
|
|
)
|
|
}
|
|
placeholder="MM/DD/YYYY"
|
|
className="h-7 text-xs bg-white"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
{patientResult.insuranceId && patientResult.dateOfBirth && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-primary hover:bg-primary/90"
|
|
onClick={handleEligibilityFromPatient}
|
|
>
|
|
<Stethoscope className="h-3 w-3 mr-1" />
|
|
Check Eligibility
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-emerald-600 hover:bg-emerald-700 text-white"
|
|
onClick={handleEligibilityAndAppointmentFromPatient}
|
|
>
|
|
<Calendar className="h-3 w-3 mr-1" />
|
|
Eligibility & Appointment Today
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Eligibility by ID ready */}
|
|
{step === "eligibility-id-ready" && eligibilityIdData && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 space-y-2">
|
|
{eligibilityIdData.patient && (
|
|
<p className="text-xs font-semibold text-blue-800">
|
|
{eligibilityIdData.patient.firstName} {eligibilityIdData.patient.lastName}
|
|
</p>
|
|
)}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-gray-500">Member ID</Label>
|
|
<Input
|
|
value={eligibilityIdData.memberId}
|
|
onChange={(e) =>
|
|
setEligibilityIdData((prev) => (prev ? { ...prev, memberId: e.target.value } : prev))
|
|
}
|
|
className="h-7 text-xs bg-white"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-gray-500">Date of Birth (MM/DD/YYYY)</Label>
|
|
<Input
|
|
value={isoToMDY(eligibilityIdData.dob)}
|
|
onChange={(e) =>
|
|
setEligibilityIdData((prev) =>
|
|
prev ? { ...prev, dob: mdyToISO(e.target.value) } : prev
|
|
)
|
|
}
|
|
placeholder="MM/DD/YYYY"
|
|
className="h-7 text-xs bg-white"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-gray-500">Insurance Type</Label>
|
|
<Select
|
|
value={eligibilityIdData.autoCheck}
|
|
onValueChange={(value) =>
|
|
setEligibilityIdData((prev) => (prev ? { ...prev, autoCheck: value } : prev))
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs bg-white">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{INSURANCE_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{eligibilityIdData.patient?.insuranceProvider && (
|
|
<p className="text-xs text-gray-500">{eligibilityIdData.patient.insuranceProvider}</p>
|
|
)}
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-primary hover:bg-primary/90"
|
|
onClick={handleEligibilityIdRun}
|
|
>
|
|
<Stethoscope className="h-3 w-3 mr-1" />
|
|
Check Eligibility
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-emerald-600 hover:bg-emerald-700 text-white"
|
|
onClick={() => handleEligibilityAndAppointment()}
|
|
>
|
|
<Calendar className="h-3 w-3 mr-1" />
|
|
Eligibility & Appointment Today
|
|
</Button>
|
|
{eligibilityIdData.appointmentDate && (
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-teal-600 hover:bg-teal-700 text-white"
|
|
onClick={() => handleEligibilityAndAppointment(eligibilityIdData!.appointmentDate!)}
|
|
>
|
|
<Calendar className="h-3 w-3 mr-1" />
|
|
Eligibility & Appointment on{" "}
|
|
{new Date(eligibilityIdData.appointmentDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
|
</Button>
|
|
)}
|
|
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Batch eligibility ready */}
|
|
{step === "batch-eligibility-ready" && batchEligibilityData && (
|
|
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-indigo-800">
|
|
{batchEligibilityData.length} patients to check:
|
|
</p>
|
|
{batchEligibilityData.map((item, i) => {
|
|
const name = item.patient
|
|
? `${item.patient.firstName ?? ""} ${item.patient.lastName ?? ""}`.trim()
|
|
: `ID: ${item.memberId}`;
|
|
return (
|
|
<p key={i} className="text-xs text-indigo-600 pl-2">
|
|
{i + 1}. {name} — DOB: {item.dob}
|
|
</p>
|
|
);
|
|
})}
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-indigo-600 hover:bg-indigo-700 text-white"
|
|
onClick={handleBatchEligibilityRun}
|
|
>
|
|
<Stethoscope className="h-3 w-3 mr-1" />
|
|
Check All ({batchEligibilityData.length} patients)
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-green-600 hover:bg-green-700 text-white"
|
|
onClick={handleBatchEligibilityAndAppointment}
|
|
>
|
|
<Calendar className="h-3 w-3 mr-1" />
|
|
Check All & Appointment Today
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Check & Claim ready */}
|
|
{step === "check-and-claim-ready" && checkAndClaimData && (
|
|
<div className="bg-teal-50 border border-teal-200 rounded-xl p-3 space-y-2">
|
|
{checkAndClaimData.patient && (
|
|
<p className="text-xs font-semibold text-teal-800">
|
|
{checkAndClaimData.patient.firstName} {checkAndClaimData.patient.lastName}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-teal-600">ID: {checkAndClaimData.memberId} · DOB: {checkAndClaimData.dob}</p>
|
|
{checkAndClaimData.matchedCodes.length > 0 && (
|
|
<div className="space-y-0.5">
|
|
<p className="text-xs font-medium text-teal-700">Claim after ACTIVE:</p>
|
|
{checkAndClaimData.matchedCodes.map((c) => (
|
|
<p key={c.code} className="text-xs text-gray-600 pl-2">
|
|
{c.code} — {c.description}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs bg-teal-600 hover:bg-teal-700 text-white"
|
|
onClick={handleCheckAndClaimRun}
|
|
>
|
|
<Stethoscope className="h-3 w-3 mr-1" />
|
|
Check & Claim
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Need insurance clarification */}
|
|
{step === "need-insurance-clarification" && clarificationData && (
|
|
<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 insurance?</p>
|
|
<p className="text-xs text-gray-500">ID: {clarificationData.memberId}</p>
|
|
<div className="flex flex-col gap-1.5 pt-1">
|
|
{clarificationData.options.map((opt) => (
|
|
<button
|
|
key={opt}
|
|
className="text-left text-xs px-3 py-1.5 rounded-lg border border-amber-300 hover:bg-amber-100 transition-colors"
|
|
onClick={() => {
|
|
addMsg("user", opt);
|
|
addMsg("bot", "Thinking...", true);
|
|
setStep("ai-loading");
|
|
apiRequest("POST", "/api/ai/internal-chat", {
|
|
message: `check ${opt} for ${clarificationData.memberId}, ${clarificationData.dob}${clarificationData.procedureNames.length > 0 ? " and claim " + clarificationData.procedureNames.join(", ") : ""}`,
|
|
clientDate: (() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; })(),
|
|
})
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
|
if (data.action === "check_and_claim_ready" && data.actionData) {
|
|
setCheckAndClaimData({
|
|
patient: data.actionData.patient ?? null,
|
|
memberId: data.actionData.memberId,
|
|
dob: data.actionData.dob,
|
|
siteKey: data.actionData.siteKey,
|
|
autoCheck: data.actionData.autoCheck,
|
|
matchedCodes: data.actionData.matchedCodes ?? [],
|
|
serviceDate: data.actionData.serviceDate ?? null,
|
|
renderingProvider: data.actionData.renderingProvider ?? null,
|
|
});
|
|
setStep("check-and-claim-ready");
|
|
} else if (data.action === "eligibility_id_ready" && data.actionData) {
|
|
setEligibilityIdData({
|
|
memberId: data.actionData.memberId,
|
|
dob: data.actionData.dob,
|
|
siteKey: data.actionData.siteKey,
|
|
autoCheck: data.actionData.autoCheck,
|
|
patient: data.actionData.patient ?? null,
|
|
appointmentDate: data.actionData.appointmentDate ?? null,
|
|
});
|
|
setStep("eligibility-id-ready");
|
|
} else {
|
|
setStep("menu");
|
|
}
|
|
})
|
|
.catch(() => {
|
|
replaceLastMsg("Sorry, something went wrong.");
|
|
setStep("menu");
|
|
});
|
|
}}
|
|
>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</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,
|
|
dob: apptSelectionData.patient?.dateOfBirth ?? null,
|
|
})
|
|
);
|
|
setChatbotPendingFiles(pendingFiles);
|
|
markJobStarted();
|
|
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>
|
|
)}
|
|
|
|
{/* Batch check & claim ready */}
|
|
{step === "batch-check-and-claim-ready" && batchCheckAndClaimData && (
|
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-purple-800">
|
|
Check & Claim for {batchCheckAndClaimData.queue.length} patients:
|
|
</p>
|
|
{batchCheckAndClaimData.queue.map((item, i) => {
|
|
const name = item.patient
|
|
? `${item.patient.firstName ?? ""} ${item.patient.lastName ?? ""}`.trim()
|
|
: `ID: ${item.memberId}`;
|
|
return (
|
|
<p key={i} className="text-xs text-purple-600 pl-2">
|
|
{i + 1}. {name} — DOB: {item.dob}
|
|
</p>
|
|
);
|
|
})}
|
|
{batchCheckAndClaimData.matchedCodes.length > 0 && (
|
|
<div className="space-y-0.5 pt-0.5">
|
|
<p className="text-xs font-medium text-purple-700">Claim after ACTIVE:</p>
|
|
{batchCheckAndClaimData.matchedCodes.map((c) => (
|
|
<p key={c.code} className="text-xs text-gray-700 pl-2">
|
|
<span className="font-medium">{c.code}</span> — {c.description}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-purple-600 hover:bg-purple-700 text-white"
|
|
onClick={() => {
|
|
const { queue, matchedCodes, renderingProvider } = batchCheckAndClaimData;
|
|
addMsg("user", `Check & claim all ${queue.length} patients`);
|
|
addMsg("bot", `Checking eligibility and claiming for ${queue.length} patients one by one...`);
|
|
const [first, ...rest] = queue;
|
|
if (rest.length > 0) {
|
|
sessionStorage.setItem("chatbot_check_claim_queue", JSON.stringify({
|
|
remaining: rest,
|
|
matchedCodes,
|
|
renderingProvider,
|
|
}));
|
|
}
|
|
sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({
|
|
codes: matchedCodes,
|
|
siteKey: first!.siteKey,
|
|
patientId: first!.patient?.id ?? null,
|
|
memberId: first!.memberId,
|
|
dob: first!.dob,
|
|
serviceDate: null,
|
|
renderingProvider,
|
|
}));
|
|
setChatbotPendingFiles(pendingFiles);
|
|
prefillAndNavigate(first!.memberId, first!.dob, first!.autoCheck);
|
|
}}
|
|
>
|
|
<Stethoscope className="h-3 w-3 mr-1" />
|
|
Check & Claim All ({batchCheckAndClaimData.queue.length} patients)
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Batch claim ready */}
|
|
{step === "batch-claim-ready" && batchClaimData && (
|
|
<div className="bg-orange-50 border border-orange-200 rounded-xl p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-orange-800">
|
|
Claim for {batchClaimData.queue.length} patients:
|
|
</p>
|
|
{batchClaimData.queue.map((item, i) => (
|
|
<p key={i} className="text-xs text-orange-600 pl-2">
|
|
{i + 1}. {item.patient.firstName} {item.patient.lastName}
|
|
</p>
|
|
))}
|
|
{batchClaimData.matchedCodes.length > 0 && (
|
|
<div className="space-y-0.5 pt-0.5">
|
|
<p className="text-xs font-medium text-orange-700">Procedures:</p>
|
|
{batchClaimData.matchedCodes.map((c) => (
|
|
<p key={c.code} className="text-xs text-gray-700 pl-2">
|
|
<span className="font-medium">{c.code}</span> — {c.description}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-orange-600 hover:bg-orange-700 text-white"
|
|
onClick={() => {
|
|
const { queue, matchedCodes, renderingProvider } = batchClaimData;
|
|
addMsg("user", `Claim all ${queue.length} patients`);
|
|
addMsg("bot", `Claiming ${queue.length} patients one by one...`);
|
|
const [first, ...rest] = queue;
|
|
if (rest.length > 0) {
|
|
sessionStorage.setItem("chatbot_claim_queue", JSON.stringify({
|
|
remaining: rest,
|
|
matchedCodes,
|
|
renderingProvider,
|
|
}));
|
|
}
|
|
if (first!.patient.id && matchedCodes.length > 0) {
|
|
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({
|
|
codes: matchedCodes,
|
|
siteKey: first!.siteKey,
|
|
serviceDate: first!.serviceDate,
|
|
autoSubmit: true,
|
|
renderingProvider: renderingProvider ?? null,
|
|
dob: first!.patient.dateOfBirth ?? null,
|
|
}));
|
|
}
|
|
setChatbotPendingFiles(pendingFiles);
|
|
markJobStarted();
|
|
const url = first!.appointmentId
|
|
? `/claims?appointmentId=${first!.appointmentId}`
|
|
: `/claims?newPatient=${first!.patient.id}`;
|
|
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
|
|
}}
|
|
>
|
|
<FileText className="h-3 w-3 mr-1" />
|
|
Claim All ({batchClaimData.queue.length} patients)
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Multi claim ready — different procedures per patient */}
|
|
{step === "multi-claim-ready" && batchClaimData && (
|
|
<div className="bg-orange-50 border border-orange-200 rounded-xl p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-orange-800">
|
|
Claim for {batchClaimData.queue.length} patients:
|
|
</p>
|
|
{batchClaimData.queue.map((item: any, i: number) => (
|
|
<div key={i} className="pl-2 space-y-0.5">
|
|
<p className="text-xs text-orange-600 font-medium">
|
|
{i + 1}. {item.patient.firstName} {item.patient.lastName}
|
|
</p>
|
|
{item.matchedCodes?.length > 0 && (
|
|
<div className="pl-3">
|
|
{item.matchedCodes.map((c: any) => (
|
|
<p key={c.code} className="text-xs text-gray-700">
|
|
<span className="font-medium">{c.code}</span> — {c.description}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="w-full h-8 text-xs bg-orange-600 hover:bg-orange-700 text-white"
|
|
onClick={() => {
|
|
const { queue, renderingProvider } = batchClaimData;
|
|
addMsg("user", `Claim all ${queue.length} patients`);
|
|
addMsg("bot", `Claiming ${queue.length} patients one by one...`);
|
|
const [first, ...rest] = queue as any[];
|
|
if (rest.length > 0) {
|
|
sessionStorage.setItem("chatbot_claim_queue", JSON.stringify({
|
|
remaining: rest,
|
|
matchedCodes: null,
|
|
renderingProvider,
|
|
}));
|
|
}
|
|
if (first!.patient.id && first!.matchedCodes?.length > 0) {
|
|
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({
|
|
codes: first!.matchedCodes,
|
|
siteKey: first!.siteKey,
|
|
serviceDate: first!.serviceDate,
|
|
autoSubmit: true,
|
|
renderingProvider: renderingProvider ?? null,
|
|
dob: first!.patient.dateOfBirth ?? null,
|
|
}));
|
|
}
|
|
setChatbotPendingFiles(pendingFiles);
|
|
markJobStarted();
|
|
const url = first!.appointmentId
|
|
? `/claims?appointmentId=${first!.appointmentId}`
|
|
: `/claims?newPatient=${first!.patient.id}`;
|
|
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
|
|
}}
|
|
>
|
|
<FileText className="h-3 w-3 mr-1" />
|
|
Claim All ({batchClaimData.queue.length} patients)
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={resetStep}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Claim ready — confirm before submitting */}
|
|
{step === "claim-ready" && claimReadyData && (() => {
|
|
const [sy, sm, sd] = (claimReadyData.serviceDate ?? "").split("-");
|
|
const dateLabel = sy ? `${sm}/${sd}/${sy}` : claimReadyData.serviceDate;
|
|
return (
|
|
<div className="bg-green-50 border border-green-200 rounded-xl p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-green-800">Confirm Claim</p>
|
|
{claimReadyData.patient && (
|
|
<p className="text-xs text-green-700 font-medium">
|
|
{claimReadyData.patient.firstName} {claimReadyData.patient.lastName}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-gray-500">Service date: {dateLabel}</p>
|
|
{claimReadyData.matchedCodes.length > 0 && (
|
|
<div className="space-y-0.5 pt-0.5">
|
|
{claimReadyData.matchedCodes.map((c) => (
|
|
<p key={c.code} className="text-xs text-gray-700 pl-1">
|
|
<span className="font-medium">{c.code}</span> — {c.description}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs bg-green-600 hover:bg-green-700 text-white"
|
|
onClick={() => {
|
|
const { patient, matchedCodes, siteKey, serviceDate, appointmentId, renderingProvider } = claimReadyData;
|
|
addMsg("user", "Confirm & submit claim");
|
|
addMsg("bot", "Opening claim...");
|
|
if (patient?.id && matchedCodes.length > 0) {
|
|
sessionStorage.setItem(
|
|
"chatbot_claim_prefill",
|
|
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true, renderingProvider: renderingProvider ?? null, dob: patient?.dateOfBirth ?? null })
|
|
);
|
|
}
|
|
setChatbotPendingFiles(pendingFiles);
|
|
markJobStarted();
|
|
const url = appointmentId
|
|
? `/claims?appointmentId=${appointmentId}`
|
|
: `/claims?newPatient=${patient?.id}`;
|
|
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
|
|
}}
|
|
>
|
|
<FileText className="h-3 w-3 mr-1" />
|
|
Confirm & Submit
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>Cancel</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* PreAuth confirmation card */}
|
|
{step === "preauth-ready" && preauthReadyData && (() => {
|
|
const [sy, sm, sd] = (preauthReadyData.serviceDate ?? "").split("-");
|
|
const dateLabel = sy ? `${sm}/${sd}/${sy}` : preauthReadyData.serviceDate;
|
|
return (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-blue-800">Confirm Pre-Authorization</p>
|
|
{preauthReadyData.patient && (
|
|
<p className="text-xs text-blue-700 font-medium">
|
|
{preauthReadyData.patient.firstName} {preauthReadyData.patient.lastName}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-gray-500">Tentative date: {dateLabel}</p>
|
|
{preauthReadyData.matchedCodes.length > 0 && (
|
|
<div className="space-y-0.5 pt-0.5">
|
|
{preauthReadyData.matchedCodes.map((c) => (
|
|
<p key={c.code} className="text-xs text-gray-700 pl-1">
|
|
<span className="font-medium">{c.code}</span>{c.toothNumber ? ` #${c.toothNumber}` : ""} — {c.description}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs bg-blue-600 hover:bg-blue-700 text-white"
|
|
onClick={() => {
|
|
const { patient, matchedCodes, siteKey, serviceDate, renderingProvider } = preauthReadyData;
|
|
addMsg("user", "Confirm & open pre-auth");
|
|
addMsg("bot", "Opening pre-auth form...");
|
|
if (patient?.id && matchedCodes.length > 0) {
|
|
sessionStorage.setItem(
|
|
"chatbot_preauth_prefill",
|
|
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, renderingProvider: renderingProvider ?? null })
|
|
);
|
|
}
|
|
setChatbotPendingFiles(pendingFiles);
|
|
const url = `/claims?newPatient=${patient?.id}&tab=preauth`;
|
|
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
|
|
}}
|
|
>
|
|
<FileText className="h-3 w-3 mr-1" />
|
|
Confirm & Open PreAuth
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>Cancel</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* CDT clarification — unknown procedure terms */}
|
|
{step === "need-cdt-clarification" && cdtClarificationData && (
|
|
<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">Unknown procedure term{cdtClarificationData.unknownPhrases.length > 1 ? "s" : ""}</p>
|
|
<div className="space-y-2">
|
|
{cdtClarificationData.unknownPhrases.map((phrase) => (
|
|
<div key={phrase} className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-700 font-medium shrink-0">"{phrase}"→</span>
|
|
<input
|
|
type="text"
|
|
placeholder="D0272"
|
|
value={cdtClarificationData.codeInputs[phrase] ?? ""}
|
|
onChange={(e) => setCdtClarificationData((prev) => prev ? {
|
|
...prev,
|
|
codeInputs: { ...prev.codeInputs, [phrase]: e.target.value.toUpperCase() },
|
|
} : prev)}
|
|
className="flex-1 rounded border border-amber-300 bg-white px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs bg-amber-600 hover:bg-amber-700 text-white"
|
|
disabled={Object.values(cdtClarificationData.codeInputs).some((v) => !v.trim())}
|
|
onClick={async () => {
|
|
const entries = Object.entries(cdtClarificationData.codeInputs);
|
|
for (const [phrase, code] of entries) {
|
|
await apiRequest("POST", "/api/ai/cdt-aliases/add", { phrase, cdtCode: code.trim() });
|
|
}
|
|
const savedPairs = entries.map(([p, c]) => `"${p}" = ${c.trim()}`).join(", ");
|
|
addMsg("user", entries.map(([p, c]) => `${p} = ${c.trim()}`).join(", "));
|
|
addMsg("bot", `Got it! Saved ${savedPairs}. Retrying...`, true);
|
|
setStep("ai-loading");
|
|
setCdtClarificationData(null);
|
|
const origMsg = cdtClarificationData.originalMessage;
|
|
try {
|
|
const history = messages.filter((m) => !m.isLoading).slice(-15).map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
|
|
const _d = new Date();
|
|
const _cd = `${_d.getFullYear()}-${String(_d.getMonth()+1).padStart(2,"0")}-${String(_d.getDate()).padStart(2,"0")}`;
|
|
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: origMsg, history, clientDate: _cd });
|
|
const data = await res.json();
|
|
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
|
if (data.action === "claim_only_ready" && data.actionData) {
|
|
const { patient, matchedCodes, siteKey, serviceDate, appointmentId, renderingProvider } = data.actionData;
|
|
if (patient?.id && matchedCodes?.length > 0) {
|
|
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true, renderingProvider: renderingProvider ?? null, dob: patient?.dateOfBirth ?? null }));
|
|
}
|
|
setChatbotPendingFiles(pendingFiles);
|
|
const url = appointmentId ? `/claims?appointmentId=${appointmentId}` : `/claims?newPatient=${patient?.id}`;
|
|
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
|
|
} else {
|
|
setStep("menu");
|
|
}
|
|
} catch {
|
|
replaceLastMsg("Sorry, something went wrong. Please try again.");
|
|
setStep("menu");
|
|
}
|
|
}}
|
|
>
|
|
Save & Retry
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>Cancel</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Persistent free-text input */}
|
|
{showFreeTextInput && (
|
|
<div className="shrink-0 border-t bg-white px-3 py-2">
|
|
{/* Attached file chips */}
|
|
{pendingFiles.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 pb-1.5">
|
|
{pendingFiles.map((f, i) => (
|
|
<span
|
|
key={i}
|
|
className="flex items-center gap-1 text-[10px] bg-blue-50 border border-blue-200 rounded px-1.5 py-0.5 text-blue-700 max-w-[140px]"
|
|
>
|
|
<Paperclip className="h-2.5 w-2.5 shrink-0" />
|
|
<span className="truncate">{f.name}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}
|
|
className="shrink-0 hover:text-red-500 ml-0.5"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-end gap-1.5">
|
|
<textarea
|
|
ref={freeTextRef}
|
|
rows={2}
|
|
placeholder='e.g. "check MARIA GONZALES" or "open claims"'
|
|
value={freeTextInput}
|
|
onChange={(e) => setFreeTextInput(e.target.value)}
|
|
onKeyDown={handleFreeTextKeyDown}
|
|
disabled={step === "ai-loading"}
|
|
className="flex-1 resize-none rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-9 w-9 p-0 shrink-0 text-gray-400 hover:text-gray-600"
|
|
title="Attach X-ray or document"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={step === "ai-loading"}
|
|
>
|
|
<Paperclip className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-9 w-9 p-0 shrink-0 text-gray-400 hover:text-gray-600"
|
|
title="Attach screenshot"
|
|
onClick={() => setIsServerPickerOpen(true)}
|
|
disabled={step === "ai-loading"}
|
|
>
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="h-9 w-9 p-0 shrink-0"
|
|
onClick={handleFreeTextSubmit}
|
|
disabled={!freeTextInput.trim() || step === "ai-loading"}
|
|
>
|
|
{step === "ai-loading" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] text-gray-400 mt-1 pl-1">Enter to send · Shift+Enter for new line · 📎 attach files</p>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/*,application/pdf"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const files = Array.from(e.target.files ?? []);
|
|
if (files.length > 0) {
|
|
setPendingFiles((prev) => [...prev, ...files].slice(0, 5));
|
|
}
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<ServerAttachmentsPickerModal
|
|
open={isServerPickerOpen}
|
|
onClose={() => setIsServerPickerOpen(false)}
|
|
onFilesSelected={(files) =>
|
|
setPendingFiles((prev) => [...prev, ...files].slice(0, 5))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|