The chatbot-extracted DOB was stored in chatbot_claim_codes but never forwarded to chatbot_claim_prefill, leaving the claim form DOB empty when the patient DB record lacked it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1145 lines
51 KiB
TypeScript
1145 lines
51 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import {
|
|
Bot,
|
|
X,
|
|
ChevronRight,
|
|
Stethoscope,
|
|
Calendar,
|
|
FileText,
|
|
MessageSquare,
|
|
Send,
|
|
Loader2,
|
|
RotateCcw,
|
|
Paperclip,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { useLocation } from "wouter";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import { setChatbotPendingFiles } from "@/lib/chatbotFileStore";
|
|
|
|
type Step =
|
|
| "menu"
|
|
| "eligibility-input"
|
|
| "eligibility-confirm"
|
|
| "ai-loading"
|
|
| "patient-found"
|
|
| "eligibility-id-ready"
|
|
| "check-and-claim-ready"
|
|
| "need-insurance-clarification"
|
|
| "need-appointment-selection"
|
|
| "need-cdt-clarification"
|
|
| "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 };
|
|
}
|
|
|
|
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?")];
|
|
}
|
|
|
|
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);
|
|
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; appointmentDate?: string | 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 [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 [, setLocation] = useLocation();
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
|
const freeTextRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages, step]);
|
|
|
|
// Persist messages across navigation (cleared on logout)
|
|
useEffect(() => {
|
|
try {
|
|
const saveable = messages.filter((m) => !m.isLoading);
|
|
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable));
|
|
} catch {}
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
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);
|
|
setCheckAndClaimData(null);
|
|
setClarificationData(null);
|
|
setApptSelectionData(null);
|
|
setCdtClarificationData(null);
|
|
setClaimReadyData(null);
|
|
setPreauthReadyData(null);
|
|
setPendingFiles([]);
|
|
};
|
|
|
|
// Full reset including message history and stored session
|
|
const reset = () => {
|
|
resetStep();
|
|
const fresh = [makeMsg("bot", "Hi! What can I help you with today?")];
|
|
try {
|
|
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh));
|
|
} catch {}
|
|
setMessages(fresh);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
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);
|
|
};
|
|
|
|
const handleEligibilityFromPatient = () => {
|
|
if (!patientResult) return;
|
|
addMsg("user", "Check eligibility now");
|
|
addMsg("bot", "Opening the eligibility check page...");
|
|
if (patientResult.insuranceId && patientResult.dateOfBirth) {
|
|
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({
|
|
memberId: patientResult.insuranceId,
|
|
dob: patientResult.dateOfBirth,
|
|
autoCheck: getAutoCheck(patientResult.dateOfBirth),
|
|
}));
|
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
|
}
|
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
|
|
};
|
|
|
|
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();
|
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
|
|
};
|
|
|
|
const handleEligibilityIdRun = () => {
|
|
if (!eligibilityIdData) return;
|
|
addMsg("user", "Check eligibility now");
|
|
addMsg("bot", "Opening the eligibility check page...");
|
|
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.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})`);
|
|
|
|
if (!eligibilityIdData.patient) {
|
|
addMsg("bot", `Running eligibility check — will add patient and create appointment for ${dateLabel} after...`);
|
|
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId, date: targetDate ?? null }));
|
|
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
|
|
return;
|
|
}
|
|
|
|
addMsg("bot", `Creating appointment for ${dateLabel}...`, true);
|
|
try {
|
|
const res = await apiRequest("POST", "/api/ai/create-appointment-today", {
|
|
patientId: eligibilityIdData.patient.id,
|
|
date: targetDate ?? undefined,
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
replaceLastMsg(data.message ?? "Could not create appointment.");
|
|
return;
|
|
}
|
|
replaceLastMsg(`Appointment added at ${data.startTime} (${data.column ?? "Column A"}) for ${data.dateLabel} — opening eligibility check page...`);
|
|
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
|
|
} catch {
|
|
replaceLastMsg("Could not create appointment. Please try again.");
|
|
}
|
|
};
|
|
|
|
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,
|
|
})
|
|
);
|
|
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);
|
|
setStep("patient-found");
|
|
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 === "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 === "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={reset}>
|
|
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>
|
|
)}
|
|
{patientResult.insuranceId && (
|
|
<p className="text-xs text-gray-500">ID: {patientResult.insuranceId}</p>
|
|
)}
|
|
{patientResult.dateOfBirth && (
|
|
<p className="text-xs text-gray-500">DOB: {patientResult.dateOfBirth}</p>
|
|
)}
|
|
<div className="flex gap-2 pt-1">
|
|
{patientResult.insuranceId && (
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 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" variant="ghost" className="h-8 text-xs" onClick={reset}>
|
|
Done
|
|
</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>
|
|
)}
|
|
<p className="text-xs text-blue-600">ID: {eligibilityIdData.memberId}</p>
|
|
<p className="text-xs text-gray-500">DOB: {eligibilityIdData.dob}</p>
|
|
{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={reset}>
|
|
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={reset}>
|
|
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={reset}>
|
|
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>
|
|
)}
|
|
|
|
{/* 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"
|
|
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 = "";
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|