Files
DentalManagementMH07/apps/Frontend/src/components/layout/chatbot.jsx
Gitead 1edf73fdc8 feat: add new frontend components, MH batch worker, and gitignore rules
- Add all new Frontend source files (pages, components, hooks, utils)
- Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py
- Add install-steps-5-13.sh setup script
- Update .gitignore to exclude runtime/sensitive data (backups, uploads,
  chat-history, keys, downloads, generated .d.ts files) while keeping folders
- Add .gitkeep to preserve empty runtime folders in git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:23:43 -04:00

1071 lines
59 KiB
JavaScript

import { useState, useRef, useEffect } from "react";
import { Bot, X, ChevronRight, Stethoscope, Calendar, FileText, 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";
let msgCounter = 0;
function makeMsg(role, text, isLoading = false) {
return { id: ++msgCounter, role, text, isLoading };
}
function getAutoCheck(dobISO) {
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) {
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() {
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() {
try {
const raw = sessionStorage.getItem(CHAT_STORAGE_KEY);
if (raw)
return JSON.parse(raw);
}
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 = null;
function saveChatHistoryToServer(msgs) {
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("menu");
const [messages, setMessages] = useState(loadSavedMessages);
const [pasteInput, setPasteInput] = useState("");
const [parseError, setParseError] = useState("");
const [eligibilityData, setEligibilityData] = useState(null);
const [freeTextInput, setFreeTextInput] = useState("");
const [patientResult, setPatientResult] = useState(null);
const [eligibilityIdData, setEligibilityIdData] = useState(null);
const [batchEligibilityData, setBatchEligibilityData] = useState(null);
const [checkAndClaimData, setCheckAndClaimData] = useState(null);
const [clarificationData, setClarificationData] = useState(null);
const [apptSelectionData, setApptSelectionData] = useState(null);
const [cdtClarificationData, setCdtClarificationData] = useState(null);
const [claimReadyData, setClaimReadyData] = useState(null);
const [batchCheckAndClaimData, setBatchCheckAndClaimData] = useState(null);
const [batchClaimData, setBatchClaimData] = useState(null);
const [preauthReadyData, setPreauthReadyData] = useState(null);
const [pendingFiles, setPendingFiles] = useState([]);
const [, setLocation] = useLocation();
const messagesEndRef = useRef(null);
const pasteRef = useRef(null);
const freeTextRef = useRef(null);
const fileInputRef = useRef(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]);
// 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, text, isLoading = false) => setMessages((prev) => [...prev, makeMsg(role, text, isLoading)]);
const replaceLastMsg = (text) => 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);
};
// 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) => {
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 = { 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?.insuranceId || !patientResult?.dateOfBirth)
return;
addMsg("user", "Check eligibility now");
addMsg("bot", "Opening the eligibility check page...");
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", "Creating appointment for today...", true);
try {
const res = await apiRequest("POST", "/api/ai/create-appointment-today", {
patientId: patientResult.id,
});
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"}) — opening eligibility check page...`);
}
catch {
replaceLastMsg("Could not create appointment — opening eligibility check page...");
}
prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth));
};
const prefillAndNavigate = (memberId, dobISO, autoCheck) => {
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 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) => {
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 === "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 = data.actionData.unknownPhrases ?? [];
const inputs = {};
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 === "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) => {
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 === "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>)}
{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 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 &amp; 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>)}
<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 &amp; 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 &amp; 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 &amp; 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 &amp; 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,
}));
prefillAndNavigate(first.memberId, first.dob, first.autoCheck);
}}>
<Stethoscope className="h-3 w-3 mr-1"/>
Check &amp; 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>)}
{/* 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 &amp; 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 &amp; 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 &amp; 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>
</>)}
</>);
}