feat: AI-powered eligibility detection from Copy Agent screenshots

Adds an "AI Detect & Check Eligibility" action to the Copy Agent page that
sends captured screenshots to Claude to extract Member ID/DOB, then opens
the chatbot's eligibility confirm step pre-filled with the result.

- POST /api/ai/detect-eligibility-info: Claude vision extraction of
  Member ID/DOB from uploaded screenshots.
- POST /api/patients/create-temp: auto-creates a placeholder "PID-<id>"
  patient (matched by insuranceId+dob) when no patient is selected, so
  screenshots can be saved immediately; the real eligibility check later
  rewrites the patient's name and Cloud Storage folder automatically via
  the existing createOrUpdatePatientByInsuranceId matching logic.
- Chatbot: Member ID/DOB on the confirm card are now editable (MM/DD/YYYY),
  with edits synced back to the patient record before the check runs.
- Fixed a useEffect ordering bug on the insurance-status page that could
  silently clear a chatbot-prefilled Member ID/DOB before the auto-trigger
  effect ran, preventing the eligibility check from ever starting.
- Added a local-disk-only screenshot backup (apps/Backend/uploads/
  screenshot-backups/) for debugging, not exposed anywhere in the UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 21:20:33 -04:00
co-authored by Claude Sonnet 5
parent 34312b62bc
commit c95d08c951
9 changed files with 748 additions and 57 deletions
+123 -3
View File
@@ -14,6 +14,7 @@ import {
Image as ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLocation } from "wouter";
import { cn } from "@/lib/utils";
@@ -77,6 +78,20 @@ function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message
return { id: ++msgCounter, role, text, isLoading };
}
// eligibilityIdData.dob is stored/sent as ISO ("YYYY-MM-DD") everywhere downstream (patient
// sync, sessionStorage prefill, matching) — these only convert for display/editing.
function isoToMDY(iso: string): string {
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return iso;
return `${m[2]}/${m[3]}/${m[1]}`;
}
function mdyToISO(mdy: string): string {
const m = mdy.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (!m) return mdy;
return `${m[3]}-${m[1]!.padStart(2, "0")}-${m[2]!.padStart(2, "0")}`;
}
function getAutoCheck(dobISO: string): "mh" | "cmsp" {
const [y, m, d] = dobISO.split("-").map(Number);
const today = new Date();
@@ -212,6 +227,9 @@ export function ChatbotButton() {
const pasteRef = useRef<HTMLTextAreaElement>(null);
const freeTextRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Set by the Copy Agent page's "AI Detect & Check Eligibility" flow when the screenshots were
// saved under a placeholder folder name; renamed to the real patient name once the user confirms.
const pendingFolderRenameRef = useRef<{ patientId: number; patientName: string } | null>(null);
// Load chat history from server on first mount (server is source of truth)
const serverLoaded = useRef(false);
@@ -237,6 +255,59 @@ export function ChatbotButton() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, step]);
// Opened externally by the Copy Agent page after it saves screenshots and runs AI
// Member ID/DOB extraction — lands the chatbot directly on the confirm step.
useEffect(() => {
function handleOpenEligibilityIdReady(e: Event) {
const detail = (e as CustomEvent).detail as {
memberId: string | null;
dob: string | null;
autoCheck: string;
patient: PatientResult | null;
patientId?: number;
pendingFolderPatientName?: string;
};
if (detail.patientId && detail.pendingFolderPatientName) {
pendingFolderRenameRef.current = {
patientId: detail.patientId,
patientName: detail.pendingFolderPatientName,
};
}
if (shouldAutoReset()) resetStep();
setOpen(true);
if (detail.memberId && detail.dob) {
setEligibilityIdData({
memberId: detail.memberId,
dob: detail.dob,
siteKey: "",
autoCheck: detail.autoCheck || "mh",
patient: detail.patient,
appointmentDate: null,
});
addMsg(
"bot",
`I read this from your screenshots — ready to check ${detail.autoCheck === "mh" ? "MassHealth" : "eligibility"} for ${
detail.patient ? `${detail.patient.firstName} ${detail.patient.lastName}` : detail.memberId
}. Confirm to proceed.`
);
setStep("eligibility-id-ready");
} else {
setPasteInput([detail.memberId, detail.dob].filter(Boolean).join(" "));
addMsg(
"bot",
"I saved your screenshots but couldn't fully read the Member ID/DOB. Please review and complete the fields below:"
);
setStep("eligibility-input");
}
}
window.addEventListener("chatbot:open-eligibility-id-ready", handleOpenEligibilityIdReady);
return () => window.removeEventListener("chatbot:open-eligibility-id-ready", handleOpenEligibilityIdReady);
}, []);
// Persist messages to sessionStorage AND server
useEffect(() => {
try {
@@ -284,6 +355,7 @@ export function ChatbotButton() {
setBatchClaimData(null);
setBatchCheckAndClaimData(null);
setPreauthReadyData(null);
pendingFolderRenameRef.current = null;
};
// Full reset including message history and stored session
@@ -377,13 +449,39 @@ export function ChatbotButton() {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
markJobStarted();
if (pendingFolderRenameRef.current) {
const { patientId, patientName } = pendingFolderRenameRef.current;
pendingFolderRenameRef.current = null;
apiRequest("PATCH", `/api/claims/patient-folder/${patientId}/confirm-name`, { patientName }).catch(() => {});
}
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
};
const handleEligibilityIdRun = () => {
// If the user hand-edited the Member ID/DOB on the "eligibility-id-ready" card (e.g. fixing a
// misread digit), push the correction to the patient record too — otherwise the real eligibility
// check (which matches by insuranceId+dob) would create a second, disconnected patient instead
// of updating this one. Surface a failure in the chat rather than swallowing it, since a silent
// failure here means the check proceeds against a stale record and silently creates a duplicate.
const syncEditedPatientFields = async (memberId: string, dob: string, patientId?: number): Promise<boolean> => {
if (!patientId) return true;
try {
await apiRequest("PUT", `/api/patients/${patientId}`, { insuranceId: memberId, dateOfBirth: dob });
return true;
} catch (err: any) {
console.error("Failed to sync edited Member ID/DOB to patient record:", err);
addMsg(
"bot",
`⚠️ Couldn't save the edited Member ID/DOB to the patient record (${err?.message ?? "unknown error"}). The eligibility check may create a duplicate patient — check the Patients page after.`
);
return false;
}
};
const handleEligibilityIdRun = async () => {
if (!eligibilityIdData) return;
addMsg("user", "Check eligibility now");
addMsg("bot", "Opening the eligibility check page...");
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
};
@@ -416,6 +514,7 @@ export function ChatbotButton() {
? new Date(targetDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "today";
addMsg("user", `Check eligibility & add to schedule (${dateLabel})`);
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
if (!eligibilityIdData.patient) {
addMsg("bot", `Running eligibility check — will add patient and create appointment for ${dateLabel} after...`);
@@ -859,8 +958,29 @@ export function ChatbotButton() {
{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>
<div className="space-y-1">
<Label className="text-[10px] text-gray-500">Member ID</Label>
<Input
value={eligibilityIdData.memberId}
onChange={(e) =>
setEligibilityIdData((prev) => (prev ? { ...prev, memberId: e.target.value } : prev))
}
className="h-7 text-xs bg-white"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-500">Date of Birth (MM/DD/YYYY)</Label>
<Input
value={isoToMDY(eligibilityIdData.dob)}
onChange={(e) =>
setEligibilityIdData((prev) =>
prev ? { ...prev, dob: mdyToISO(e.target.value) } : prev
)
}
placeholder="MM/DD/YYYY"
className="h-7 text-xs bg-white"
/>
</div>
{eligibilityIdData.patient?.insuranceProvider && (
<p className="text-xs text-gray-500">{eligibilityIdData.patient.insuranceProvider}</p>
)}
+358 -43
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { Camera, Copy, RotateCcw, Save } from "lucide-react";
import { Camera, Copy, Plus, RotateCcw, Save, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -33,6 +33,20 @@ function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
return canvas.toDataURL("image/png");
}
interface BatchedScreenshot {
id: string;
dataUrl: string;
fileName: string;
}
interface TempPatient {
id: number;
firstName: string | null;
lastName: string | null;
}
const PENDING_SCREENSHOTS_KEY = "copy_agent_pending_screenshots";
export default function AiCopyAgentPage() {
const { toast } = useToast();
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
@@ -43,6 +57,8 @@ export default function AiCopyAgentPage() {
const [isCapturing, setIsCapturing] = useState(false);
const [countdown, setCountdown] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [batch, setBatch] = useState<BatchedScreenshot[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -132,47 +148,97 @@ export default function AiCopyAgentPage() {
handleCapture();
};
const clearActive = () => {
setScreenshot(null);
setFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
};
// Applies the current crop to the active screenshot and returns it, without touching state.
const finalizeActive = (): BatchedScreenshot | null => {
if (!screenshot) return null;
const croppedImage =
completedCrop && imgRef.current
? getCroppedDataUrl(imgRef.current, completedCrop)
: screenshot;
const safeName =
fileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
return { id: crypto.randomUUID(), dataUrl: croppedImage, fileName: safeName };
};
const handleAddAnother = async () => {
const item = finalizeActive();
if (!item) return;
setBatch((prev) => [...prev, item]);
clearActive();
await handleCapture();
};
const handleRemoveBatchItem = (id: string) => {
setBatch((prev) => prev.filter((item) => item.id !== id));
};
const getPatientName = (patient: Patient) =>
patient.firstName && patient.lastName
? `${patient.firstName} ${patient.lastName}`
: patient.firstName ?? `patient-${patient.id}`;
const itemsToFiles = async (items: BatchedScreenshot[]): Promise<File[]> => {
const files: File[] = [];
for (const item of items) {
const response = await fetch(item.dataUrl);
const blob = await response.blob();
files.push(new File([blob], `${item.fileName}.png`, { type: "image/png" }));
}
return files;
};
const uploadToPatient = async (
patientId: number | undefined,
patientName: string,
files: File[],
pending: boolean
) => {
const formData = new FormData();
formData.append("patientId", String(patientId));
formData.append("patientName", patientName);
if (pending) formData.append("pending", "true");
// Lets the backend keep a local-disk-only debug copy — not shown anywhere in the UI.
formData.append("screenshotBackup", "true");
for (const file of files) formData.append("files", file);
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
};
const getPendingItems = (): BatchedScreenshot[] => {
const items = [...batch];
const active = finalizeActive();
if (active) items.push(active);
return items;
};
const handleSave = async () => {
if (!screenshot || !selectedPatient) return;
if (!selectedPatient) return;
const items = getPendingItems();
if (!items.length) return;
setIsSaving(true);
try {
const croppedImage =
completedCrop && imgRef.current
? getCroppedDataUrl(imgRef.current, completedCrop)
: screenshot;
const response = await fetch(croppedImage);
const blob = await response.blob();
const safeName =
fileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
const file = new File([blob], `${safeName}.png`, { type: "image/png" });
const patientName =
selectedPatient.firstName && selectedPatient.lastName
? `${selectedPatient.firstName} ${selectedPatient.lastName}`
: selectedPatient.firstName ?? `patient-${selectedPatient.id}`;
const formData = new FormData();
formData.append("patientId", String(selectedPatient.id));
formData.append("patientName", patientName);
formData.append("files", file);
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
const patientName = getPatientName(selectedPatient);
const files = await itemsToFiles(items);
await uploadToPatient(selectedPatient.id, patientName, files, false);
toast({
title: "Saved",
description: `Screenshot saved to ${patientName}'s attachments.`,
description: `${items.length} screenshot${items.length > 1 ? "s" : ""} saved to ${patientName}'s attachments.`,
});
setScreenshot(null);
setFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
setBatch([]);
clearActive();
} catch (error) {
console.error("Error saving screenshot:", error);
toast({
title: "Error",
description: "Failed to save screenshot to patient attachments.",
description: "Failed to save screenshots to patient attachments.",
variant: "destructive",
});
} finally {
@@ -180,6 +246,193 @@ export default function AiCopyAgentPage() {
}
};
const detectFromFiles = async (files: File[]) => {
const detectFormData = new FormData();
for (const file of files) detectFormData.append("files", file);
const response = await apiRequest("POST", "/api/ai/detect-eligibility-info", detectFormData);
return response.json();
};
const handleAiDetectAndCheck = async () => {
const items = getPendingItems();
if (!items.length) return;
if (selectedPatient) {
await runAiDetectForSelectedPatient(selectedPatient, items);
} else {
await runAiDetectWithoutPatient(items);
}
};
const runAiDetectForSelectedPatient = async (patient: Patient, items: BatchedScreenshot[]) => {
setIsDetecting(true);
try {
const patientName = getPatientName(patient);
const files = await itemsToFiles(items);
const [uploadResult, detectResult] = await Promise.allSettled([
uploadToPatient(patient.id, patientName, files, true),
detectFromFiles(files),
]);
const saved = uploadResult.status === "fulfilled";
if (saved) {
setBatch([]);
clearActive();
}
if (detectResult.status === "rejected" || detectResult.value?.error) {
toast({
title: saved ? "Saved, but AI detection failed" : "Error",
description: saved
? "Screenshots saved. Enter the Member ID/DOB manually in the chatbot to check eligibility."
: "Failed to save screenshots and detect Member ID/DOB.",
variant: saved ? undefined : "destructive",
});
return;
}
if (!saved) {
toast({
title: "Detected, but save failed",
description: "Read the Member ID/DOB, but failed to save the screenshots. Try Save All again.",
variant: "destructive",
});
}
const memberId: string | null = detectResult.value?.data?.memberId ?? null;
const dob: string | null = detectResult.value?.data?.dob ?? null;
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: {
memberId,
dob,
autoCheck: "mh",
patient: {
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
insuranceId: patient.insuranceId ?? null,
insuranceProvider: null,
dateOfBirth: patient.dateOfBirth ?? null,
},
patientId: patient.id,
pendingFolderPatientName: patientName,
},
})
);
} finally {
setIsDetecting(false);
}
};
// No patient selected: detect first (need memberId+dob to create a patient at all), then
// either auto-create a temp "PID-<id>" patient and save under it, or — if detection came back
// incomplete — leave the screenshots unsaved and let the user complete the details in the
// chatbot manually; insurance-status-page.tsx picks the pending screenshots back up once that
// manual check resolves to a real patient.
const runAiDetectWithoutPatient = async (items: BatchedScreenshot[]) => {
setIsDetecting(true);
try {
const files = await itemsToFiles(items);
let detected: { memberId: string | null; dob: string | null };
try {
const json = await detectFromFiles(files);
if (json?.error) throw new Error(json.message || "Detection failed");
detected = { memberId: json?.data?.memberId ?? null, dob: json?.data?.dob ?? null };
} catch (error) {
console.error("Error detecting Member ID/DOB:", error);
toast({
title: "Error",
description: "Failed to read the screenshots. Please try again.",
variant: "destructive",
});
return;
}
const { memberId, dob } = detected;
if (memberId && dob) {
let tempPatient: TempPatient;
try {
const res = await apiRequest("POST", "/api/patients/create-temp", { memberId, dob });
const json = await res.json();
tempPatient = json.data;
} catch (error) {
console.error("Error creating temp patient:", error);
toast({
title: "Error",
description: "Failed to create a patient record for this screenshot.",
variant: "destructive",
});
return;
}
const patientName = getPatientName(tempPatient as Patient);
try {
await uploadToPatient(tempPatient.id, patientName, files, false);
} catch (error) {
console.error("Error saving screenshots to temp patient:", error);
toast({
title: "Detected, but save failed",
description: `Created ${patientName}, but failed to save the screenshots. Select them from the patient list and try Save All.`,
variant: "destructive",
});
return;
}
setBatch([]);
clearActive();
setSelectedPatient(tempPatient as Patient);
toast({
title: "Patient created",
description: `Saved as ${patientName}. Its name will update automatically once the eligibility check confirms it.`,
});
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: {
memberId,
dob,
autoCheck: "mh",
patient: {
id: tempPatient.id,
firstName: tempPatient.firstName,
lastName: tempPatient.lastName,
insuranceId: memberId,
insuranceProvider: null,
dateOfBirth: dob,
},
patientId: tempPatient.id,
},
})
);
} else {
try {
sessionStorage.setItem(
PENDING_SCREENSHOTS_KEY,
JSON.stringify(items.map((item) => ({ dataUrl: item.dataUrl, fileName: item.fileName })))
);
} catch {}
toast({
title: "Couldn't read Member ID/DOB",
description:
"Complete the details in the chatbot — your screenshots will attach automatically once that check finishes.",
});
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: { memberId, dob, autoCheck: "mh", patient: null },
})
);
}
} finally {
setIsDetecting(false);
}
};
return (
<div className="container mx-auto space-y-6">
@@ -200,10 +453,34 @@ export default function AiCopyAgentPage() {
<CardHeader>
<CardTitle>Capture Screenshot</CardTitle>
<CardDescription>
Capture your screen and save it to a patient's attachments.
Capture your screen and save it to a patient's attachments. Take multiple
screenshots and save them together as one batch.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{batch.length > 0 && (
<div className="flex flex-wrap gap-2">
{batch.map((item) => (
<div key={item.id} className="relative">
<img
src={item.dataUrl}
alt={item.fileName}
className="h-16 w-16 rounded-md border object-cover"
/>
<button
type="button"
onClick={() => handleRemoveBatchItem(item.id)}
disabled={isSaving}
className="absolute -top-1.5 -right-1.5 rounded-full bg-background border shadow-sm p-0.5"
title="Remove from batch"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{screenshot ? (
<div className="space-y-4">
<ReactCrop
@@ -238,30 +515,67 @@ export default function AiCopyAgentPage() {
<RotateCcw className="h-4 w-4 mr-2" />
Retake
</Button>
<Button variant="outline" onClick={handleAddAnother} disabled={isCapturing || isSaving}>
<Plus className="h-4 w-4 mr-2" />
Add Another
</Button>
<Button
onClick={handleSave}
disabled={!selectedPatient || isSaving}
disabled={!selectedPatient || isSaving || isDetecting}
title={!selectedPatient ? "Select a patient below first" : undefined}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : "Save to Patient"}
{isSaving ? "Saving..." : `Save All${batch.length ? ` (${batch.length + 1})` : ""}`}
</Button>
<Button
variant="secondary"
onClick={handleAiDetectAndCheck}
disabled={isSaving || isDetecting}
>
<Sparkles className="h-4 w-4 mr-2" />
{isDetecting ? "Detecting..." : "AI Detect & Check Eligibility"}
</Button>
</div>
{!selectedPatient && (
<p className="text-xs text-muted-foreground">
Select a patient below to enable saving.
Select a patient below to enable Save All, or use "AI Detect &amp; Check
Eligibility" without selecting one to create a patient automatically.
</p>
)}
</div>
) : (
<Button onClick={handleCapture} disabled={isCapturing}>
<Camera className="h-4 w-4 mr-2" />
{countdown !== null
? `Capturing in ${countdown}...`
: isCapturing
? "Waiting for screen share..."
: "Take Screenshot"}
</Button>
<div className="flex flex-wrap gap-3">
<Button onClick={handleCapture} disabled={isCapturing}>
<Camera className="h-4 w-4 mr-2" />
{countdown !== null
? `Capturing in ${countdown}...`
: isCapturing
? "Waiting for screen share..."
: batch.length > 0
? "Take Another Screenshot"
: "Take Screenshot"}
</Button>
{batch.length > 0 && (
<>
<Button
onClick={handleSave}
disabled={!selectedPatient || isSaving || isDetecting}
title={!selectedPatient ? "Select a patient below first" : undefined}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : `Save All (${batch.length})`}
</Button>
<Button
variant="secondary"
onClick={handleAiDetectAndCheck}
disabled={isSaving || isDetecting}
>
<Sparkles className="h-4 w-4 mr-2" />
{isDetecting ? "Detecting..." : "AI Detect & Check Eligibility"}
</Button>
</>
)}
</div>
)}
</CardContent>
</Card>
@@ -271,7 +585,8 @@ export default function AiCopyAgentPage() {
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select the patient this screenshot belongs to.
Select the patient this screenshot belongs to, or leave unselected and use
"AI Detect &amp; Check Eligibility" to create one automatically.
</CardDescription>
</CardHeader>
<CardContent>
@@ -94,6 +94,8 @@ function waitForSeleniumJob(
});
}
const PENDING_COPY_AGENT_SCREENSHOTS_KEY = "copy_agent_pending_screenshots";
export default function InsuranceStatusPage() {
const { user } = useAuth();
const { toast } = useToast();
@@ -181,18 +183,32 @@ export default function InsuranceStatusPage() {
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
if (id) setMemberId(id);
if (dob) {
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD
const normalized = /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dob)
? (() => { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })()
: dob;
setDateOfBirth(parseLocalDate(normalized));
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD.
// A malformed date (e.g. hand-edited in the chatbot) must not abort the rest of
// this function — otherwise pendingAutoCheck/prefillTick below never get set and
// the auto-trigger effect silently never fires (no error, Selenium never launches).
try {
const normalized = /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dob)
? (() => { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })()
: dob;
setDateOfBirth(parseLocalDate(normalized));
} catch (dobErr) {
console.error("[insurance-status] failed to parse prefilled DOB:", dob, dobErr);
toast({
title: "Invalid Date of Birth",
description: `Couldn't parse "${dob}" — please re-enter the Date of Birth below and try again.`,
variant: "destructive",
});
}
}
if (ac) pendingAutoCheck.current = ac;
sessionStorage.removeItem("chatbot_eligibility");
// Increment tick so the auto-trigger effect re-fires even when
// memberId/dob are unchanged (same patient submitted a second time).
setPrefillTick((n) => n + 1);
} catch {}
} catch (err) {
console.error("[insurance-status] failed to apply chatbot eligibility prefill:", err);
}
};
apply();
window.addEventListener("chatbot:eligibility-prefill", apply);
@@ -201,6 +217,7 @@ export default function InsuranceStatusPage() {
}, []);
// Populate fields from selected patient
const didMountSelectedPatientEffect = useRef(false);
useEffect(() => {
if (selectedPatient) {
setMemberId(selectedPatient.insuranceId ?? "");
@@ -212,12 +229,16 @@ export default function InsuranceStatusPage() {
? parseLocalDate(selectedPatient.dateOfBirth)
: selectedPatient.dateOfBirth;
setDateOfBirth(dob ?? null);
} else {
} else if (didMountSelectedPatientEffect.current) {
// Only clear on an actual deselection (user action) — not on the initial mount, where
// selectedPatient starts null by default and would otherwise wipe out values the
// chatbot-prefill effect (above) just set from sessionStorage on this same page load.
setMemberId("");
setFirstName("");
setLastName("");
setDateOfBirth(null);
}
didMountSelectedPatientEffect.current = true;
}, [selectedPatient]);
// Auto-lookup patient by member ID when typed manually
@@ -286,6 +307,49 @@ export default function InsuranceStatusPage() {
return new Date() < cutoff;
};
// If the Copy Agent page couldn't read a Member ID/DOB from a screenshot, it stashes the
// screenshot(s) here and lets the user complete the check manually. Once that check resolves
// to a real patient, attach the originally-captured screenshots to that patient's Attachments.
const flushPendingCopyAgentScreenshots = async (jobResult: any) => {
let raw: string | null = null;
try {
raw = sessionStorage.getItem(PENDING_COPY_AGENT_SCREENSHOTS_KEY);
} catch {}
if (!raw || !jobResult?.patientId) return;
try {
sessionStorage.removeItem(PENDING_COPY_AGENT_SCREENSHOTS_KEY);
const items: { dataUrl: string; fileName: string }[] = JSON.parse(raw);
if (!items.length) return;
let patientName = `Patient ${jobResult.patientId}`;
try {
const res = await apiRequest("GET", `/api/patients/${jobResult.patientId}`);
const patient: Patient = await res.json();
const full = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
if (full) patientName = full;
} catch {}
const formData = new FormData();
formData.append("patientId", String(jobResult.patientId));
formData.append("patientName", patientName);
formData.append("screenshotBackup", "true");
for (const item of items) {
const blobRes = await fetch(item.dataUrl);
const blob = await blobRes.blob();
formData.append("files", new File([blob], `${item.fileName}.png`, { type: "image/png" }));
}
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
toast({
title: "Screenshots attached",
description: `Screenshots from Copy Agent were attached to ${patientName}'s attachments.`,
});
} catch (err) {
console.error("Failed to attach pending Copy Agent screenshots:", err);
}
};
// Shared: run MH eligibility selenium job, return jobResult or throw
const runMHEligibilitySelenium = async (): Promise<any> => {
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
@@ -325,9 +389,11 @@ export default function InsuranceStatusPage() {
}),
);
return waitForSeleniumJob(jobId, (msg) =>
const jobResult = await waitForSeleniumJob(jobId, (msg) =>
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
);
await flushPendingCopyAgentScreenshots(jobResult);
return jobResult;
};
const handleAddPatient = async () => {