feat: AI chat file uploads, RCT/PA tooth mapping, check+claim flow, service date column

- Chatbot: add paperclip button to attach X-ray/PDF files to claim submissions;
  files flow through chatbotFileStore into claim-form uploadedFiles for Selenium
- CDT lookup: auto-select D3310/D3320/D3330 by tooth number for RCT; strip #NN
  from procedure names before alias lookup so "1 pa, #3" → D0220 tooth 3;
  add "1 pa" alias for D0220; expand multi-PA notation via AI prompt rule
- AI classifier: add navigate_eligibility intent ("check mh" → /insurance-status);
  fix duplicate intent entry; add RCT and multi-PA prompt rules
- Check+claim flow: pass serviceDate through check_and_claim_ready actionData;
  tryClaimFromChatbot skips PDF preview and navigates straight to claims with
  memberId fallback lookup; wired into all provider onPdfReady callbacks
- Claims table: add Service Date column between Patient Name and Submission Date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-07 23:47:48 -04:00
parent 19bb5c1145
commit 967a53fc6c
8 changed files with 243 additions and 41 deletions

View File

@@ -32,6 +32,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { takeChatbotPendingFiles } from "@/lib/chatbotFileStore";
import {
Claim,
ClaimFileMeta,
@@ -493,6 +494,13 @@ export function ClaimForm({
// Prefill service lines (and optional service date) from chatbot claim_only flow
useEffect(() => {
const raw = sessionStorage.getItem("chatbot_claim_prefill");
const chatbotFiles = takeChatbotPendingFiles();
if (!raw && chatbotFiles.length === 0) return;
if (chatbotFiles.length > 0) {
setForm((prev) => ({ ...prev, uploadedFiles: chatbotFiles }));
}
if (!raw) return;
try {
const { codes, serviceDate } = JSON.parse(raw) as {

View File

@@ -316,6 +316,7 @@ export default function ClaimsRecentTable({
<TableHead>Claim No</TableHead>
<TableHead>PreAuth No</TableHead>
<TableHead>Patient Name</TableHead>
<TableHead>Service Date</TableHead>
<TableHead>Submission Date</TableHead>
<TableHead>Insurance Provider</TableHead>
<TableHead>Member ID</TableHead>
@@ -400,6 +401,11 @@ export default function ClaimsRecentTable({
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.serviceDate ? formatDateToHumanReadable(claim.serviceDate) : "—"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{formatDateToHumanReadable(claim.createdAt!)}

View File

@@ -10,12 +10,14 @@ import {
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"
@@ -59,6 +61,7 @@ interface CheckAndClaimData {
siteKey: string;
autoCheck: string;
matchedCodes: { code: string; description: string }[];
serviceDate?: string | null;
}
let msgCounter = 0;
@@ -153,10 +156,12 @@ export function ChatbotButton() {
serviceDate: string;
appointmentId: number | 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" });
@@ -204,6 +209,7 @@ export function ChatbotButton() {
setApptSelectionData(null);
setCdtClarificationData(null);
setClaimReadyData(null);
setPendingFiles([]);
};
// Full reset including message history and stored session
@@ -306,6 +312,7 @@ export function ChatbotButton() {
patientId: checkAndClaimData.patient?.id ?? null,
memberId: checkAndClaimData.memberId,
dob: checkAndClaimData.dob,
serviceDate: checkAndClaimData.serviceDate ?? null,
})
);
prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck);
@@ -329,7 +336,12 @@ export function ChatbotButton() {
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history, clientDate });
const data = await res.json();
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
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);
@@ -365,6 +377,7 @@ export function ChatbotButton() {
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
matchedCodes: data.actionData.matchedCodes ?? [],
serviceDate: data.actionData.serviceDate ?? null,
});
setStep("check-and-claim-ready");
return;
@@ -720,6 +733,7 @@ export function ChatbotButton() {
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
matchedCodes: data.actionData.matchedCodes ?? [],
serviceDate: data.actionData.serviceDate ?? null,
});
setStep("check-and-claim-ready");
} else if (data.action === "eligibility_id_ready" && data.actionData) {
@@ -772,6 +786,7 @@ export function ChatbotButton() {
autoSubmit: true,
})
);
setChatbotPendingFiles(pendingFiles);
markJobStarted();
setTimeout(() => {
setLocation(`/claims?appointmentId=${opt.appointmentId}`);
@@ -826,6 +841,7 @@ export function ChatbotButton() {
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
);
}
setChatbotPendingFiles(pendingFiles);
markJobStarted();
const url = appointmentId
? `/claims?appointmentId=${appointmentId}`
@@ -891,6 +907,7 @@ export function ChatbotButton() {
if (patient?.id && matchedCodes?.length > 0) {
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true }));
}
setChatbotPendingFiles(pendingFiles);
const url = appointmentId ? `/claims?appointmentId=${appointmentId}` : `/claims?newPatient=${patient?.id}`;
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
} else {
@@ -915,7 +932,28 @@ export function ChatbotButton() {
{/* Persistent free-text input */}
{showFreeTextInput && (
<div className="shrink-0 border-t bg-white px-3 py-2">
<div className="flex items-end gap-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}
@@ -926,6 +964,16 @@ export function ChatbotButton() {
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"
@@ -939,7 +987,21 @@ export function ChatbotButton() {
)}
</Button>
</div>
<p className="text-[10px] text-gray-400 mt-1 pl-1">Enter to send · Shift+Enter for new line</p>
<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>

View File

@@ -0,0 +1,12 @@
let pendingFiles: File[] = [];
export function setChatbotPendingFiles(files: File[]) {
pendingFiles = [...files];
}
/** Returns and clears the stored files (consume-once). */
export function takeChatbotPendingFiles(): File[] {
const files = [...pendingFiles];
pendingFiles = [];
return files;
}

View File

@@ -522,6 +522,8 @@ export default function InsuranceStatusPage() {
variant: "default",
});
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
if (claimed) return;
setSelectedPatient(null);
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
@@ -649,6 +651,41 @@ export default function InsuranceStatusPage() {
}
};
// After any eligibility check succeeds, check if chatbot wanted auto-claim.
// PDF is already saved to Documents by the Selenium worker before this is called.
// Returns true if a chatbot claim was pending (caller should skip opening the preview).
const tryClaimFromChatbot = async (resolvedPatientId?: number | null): Promise<boolean> => {
try {
const raw = sessionStorage.getItem("chatbot_claim_codes");
if (!raw) return false;
const { codes, siteKey, patientId, memberId: storedMemberId, serviceDate } = JSON.parse(raw);
sessionStorage.removeItem("chatbot_claim_codes");
let pid: number | null = resolvedPatientId ?? patientId ?? null;
// Fallback: look up patient by memberId if patientId wasn't stored
if (!pid && storedMemberId) {
try {
const res = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(storedMemberId)}`);
if (res.ok) {
const p = await res.json();
pid = p?.id ?? null;
}
} catch {}
}
if (!codes?.length || !pid) return false;
sessionStorage.setItem(
"chatbot_claim_prefill",
JSON.stringify({ codes, siteKey, serviceDate: serviceDate ?? null, autoSubmit: true })
);
setLocation(`/claims?newPatient=${pid}`);
return true;
} catch {}
return false;
};
// Redirect from schedule page "Check Eligibility": prefill patient + optionally auto-trigger or scroll
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -886,12 +923,13 @@ export default function InsuranceStatusPage() {
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "ddma"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`,
);
setPreviewOpen(true);
onPdfReady={async (pdfId, fallbackFilename) => {
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
if (!claimed) {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`);
setPreviewOpen(true);
}
}}
/>
</div>
@@ -905,12 +943,13 @@ export default function InsuranceStatusPage() {
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "delta-ins"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`,
);
setPreviewOpen(true);
onPdfReady={async (pdfId, fallbackFilename) => {
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
if (!claimed) {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`);
setPreviewOpen(true);
}
}}
/>
</div>
@@ -944,12 +983,13 @@ export default function InsuranceStatusPage() {
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "tufts-sco"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`,
);
setPreviewOpen(true);
onPdfReady={async (pdfId, fallbackFilename) => {
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
if (!claimed) {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`);
setPreviewOpen(true);
}
}}
/>
</div>
@@ -963,12 +1003,13 @@ export default function InsuranceStatusPage() {
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "united-sco"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`,
);
setPreviewOpen(true);
onPdfReady={async (pdfId, fallbackFilename) => {
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
if (!claimed) {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
setPreviewOpen(true);
}
}}
/>
</div>
@@ -982,12 +1023,13 @@ export default function InsuranceStatusPage() {
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "cca"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`,
);
setPreviewOpen(true);
onPdfReady={async (pdfId, fallbackFilename) => {
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
if (!claimed) {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_cca_${memberId}.pdf`);
setPreviewOpen(true);
}
}}
/>
</div>