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
parent 34312b62bc
commit c95d08c951
9 changed files with 748 additions and 57 deletions

View File

@@ -30,6 +30,7 @@ export interface EligibilityProcessorResult {
patientUpdateStatus?: string;
pdfUploadStatus?: string;
pdfFileId?: number | string | null;
patientId?: number | null;
}
export async function runEligibilityProcessor(
@@ -109,6 +110,8 @@ export async function runEligibilityProcessor(
throw new Error(`Failed to create/update patient: ${e.message}`);
}
outputResult.patientId = patient?.id ?? null;
// 5) Update patient status
if (patient && patient.id !== undefined) {
let newStatus = "UNKNOWN";
@@ -121,13 +124,24 @@ export async function runEligibilityProcessor(
await storage.updatePatient(patient.id, updates);
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// Rename the Copy Agent page's temp folder ("PID-<id>") to the real name now that we know
// it — only touches folders still using that placeholder pattern, never a normal patient's.
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`;
try {
const existingFolder = await storage.getPatientFolder(patient.id);
if (existingFolder && /^PID-\d+$/.test(existingFolder.name) && patientName) {
await storage.updateFolder((existingFolder as any).id, { name: patientName });
}
} catch (e) {
console.error("[eligibilityProcessor] temp folder rename failed:", e);
}
// 6) Save PDF
let createdPdfFileId: number | string | null = null;
if (seleniumResult.pdf_path?.endsWith(".pdf")) {
try {
const pdfBuffer = await fs.readFile(seleniumResult.pdf_path);
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`;
const cloudFile = await storage.savePdfToCloudStorage(
userId,

View File

@@ -2,10 +2,15 @@ import express, { Request, Response } from "express";
import fs from "fs";
import os from "os";
import path from "path";
import multer from "multer";
import { storage } from "../storage";
import { classifyInternalChat } from "../ai/internal-chat-graph";
import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow";
import { resolveAiProvider } from "../ai/llm-factory";
import { resolveAiProvider, getLlm } from "../ai/llm-factory";
import { backupScreenshots } from "../utils/screenshotBackup";
const eligibilityImageUpload = multer({ storage: multer.memoryStorage() });
const ELIGIBILITY_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/jpg"]);
const CHAT_HISTORY_DIR = path.join(__dirname, "..", "..", "chat-history");
@@ -336,6 +341,78 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
}
});
// POST /api/ai/detect-eligibility-info
// Sends screenshot(s) to the configured vision-capable AI to extract a Member ID and DOB,
// used by the Copy Agent page's "AI Detect & Check Eligibility" action.
router.post(
"/detect-eligibility-info",
eligibilityImageUpload.array("files", 10),
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const files = req.files as Express.Multer.File[] | undefined;
if (!files?.length) return res.status(400).json({ error: "No files uploaded" });
const badFile = files.find((f) => !ELIGIBILITY_IMAGE_MIMES.has(f.mimetype.toLowerCase()));
if (badFile) {
return res.status(400).json({ error: `Unsupported file type: ${badFile.mimetype}` });
}
backupScreenshots(files);
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
return res.status(200).json({ error: true, message: "AI is not configured. Please add an API key in AI Settings." });
}
if (activeAi.provider !== "claude") {
return res.status(200).json({ error: true, message: "Screenshot detection requires Claude to be the active AI provider." });
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const content: Array<Record<string, unknown>> = [
{
type: "text",
text:
"Extract the Member ID and Date of Birth from this dental/insurance eligibility screenshot. " +
'Respond with strict JSON only, no prose, no markdown fences: {"memberId": string|null, "dob": "YYYY-MM-DD"|null}',
},
...files.map((f) => ({
type: "image_url",
image_url: { url: `data:${f.mimetype};base64,${f.buffer.toString("base64")}` },
})),
];
const response = await llm.invoke([{ role: "user", content }] as any);
let memberId: string | null = null;
let dob: string | null = null;
try {
const raw = String(response.content).trim();
const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
const parsed = JSON.parse(jsonStr) as { memberId?: string | null; dob?: string | null };
// Sanitize once here so this is the single source of truth for the memberId
// used downstream (temp-patient creation, chatbot prefill, real eligibility check) —
// any mismatch in formatting would cause createOrUpdatePatientByInsuranceId to create
// a second patient instead of matching the temp one.
const cleanedMemberId = (parsed.memberId || "").replace(/[^A-Za-z0-9]/g, "");
memberId = cleanedMemberId || null;
dob = parsed.dob || null;
} catch (parseErr) {
console.error("[detect-eligibility-info] failed to parse AI response", parseErr);
}
return res.status(200).json({ error: false, data: { memberId, dob } });
} catch (err) {
console.error("[detect-eligibility-info]", err);
return res.status(500).json({ error: true, message: "Failed to detect eligibility info", details: String(err) });
}
}
);
router.post("/create-appointment-today", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;

View File

@@ -19,6 +19,7 @@ import {
} from "@repo/db/types";
import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient";
import { formatDobForAgent } from "../utils/dateUtils";
import { backupScreenshots } from "../utils/screenshotBackup";
const router = Router();
@@ -106,13 +107,22 @@ router.post(
const patientId = Number(req.body.patientId);
const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim();
const pending = String(req.body.pending || "") === "true";
const isScreenshotUpload = String(req.body.screenshotBackup || "") === "true";
if (!patientId || isNaN(patientId)) {
return res.status(400).json({ error: "Invalid patientId" });
}
if (isScreenshotUpload) backupScreenshots(files);
try {
const folder = await storage.getOrCreatePatientFolder(req.user.id, patientId, patientName);
let folderName = patientName;
if (pending) {
const existingFolder = await storage.getPatientFolder(patientId);
if (!existingFolder) folderName = "Pending Review";
}
const folder = await storage.getOrCreatePatientFolder(req.user.id, patientId, folderName);
const attachmentsFolder = await storage.getOrCreateSubfolder(
req.user.id,
(folder as any).id,
@@ -158,6 +168,33 @@ router.post(
}
);
// PATCH /api/claims/patient-folder/:patientId/confirm-name
// Renames a patient's Cloud Storage folder from its placeholder ("Pending Review") to the
// real patient name once the user has confirmed the attachments belong to that patient.
router.patch(
"/patient-folder/:patientId/confirm-name",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const patientId = Number(req.params.patientId);
const patientName = String(req.body.patientName || "").replace(/[/\\?%*:|"<>]/g, "-").trim();
if (!patientId || isNaN(patientId) || !patientName) {
return res.status(400).json({ error: "Invalid patientId or patientName" });
}
try {
const folder = await storage.getPatientFolder(patientId);
if (!folder) return res.status(404).json({ error: "Patient folder not found" });
await storage.updateFolder((folder as any).id, { name: patientName });
return res.json({ error: false });
} catch (err: any) {
console.error("[confirm-name]", err);
return res.status(500).json({ error: "Failed to rename patient folder", message: err?.message });
}
}
);
// GET /api/claims/patient-attachments?patientId=123
// Lists this patient's previously saved Cloud Storage "Attachments" (e.g. Copy Agent screenshots)
// so they can be picked and re-attached to a claim/preauth without re-uploading from disk.

View File

@@ -4,6 +4,7 @@ import { storage } from "../storage";
import { z } from "zod";
import { insertPatientSchema, updatePatientSchema } from "@repo/db/types";
import { normalizeInsuranceId } from "../utils/helpers";
import { createOrUpdatePatientByInsuranceId } from "../queue/processors/_shared";
const router = Router();
@@ -190,6 +191,38 @@ router.get(
}
);
// POST /api/patients/create-temp
// Creates (or reuses) a lightweight placeholder patient — "PID-<id>" — from a Member ID/DOB
// read off a screenshot by the Copy Agent page's AI Detect flow, before the real name is known.
// Uses the same memberId+dob matching the actual eligibility check will later use, so that
// check automatically rewrites this patient's real name once it runs (see eligibilityProcessor.ts).
router.post("/create-temp", async (req: Request, res: Response): Promise<any> => {
try {
if (!req.user?.id) return res.status(401).json({ message: "Unauthorized" });
const memberId = String(req.body.memberId || "").trim();
const dob = req.body.dob;
if (!memberId || !dob) {
return res.status(400).json({ message: "memberId and dob are required" });
}
let patient = await createOrUpdatePatientByInsuranceId({
insuranceId: memberId,
dob,
userId: req.user.id,
});
if (patient && patient.id !== undefined && !patient.firstName) {
await storage.updatePatient(patient.id, { firstName: `PID-${patient.id}` });
patient = (await storage.getPatient(patient.id)) ?? patient;
}
return res.status(200).json({ error: false, data: patient });
} catch (error: any) {
return res.status(500).json({ message: "Failed to create temp patient", details: error?.message });
}
});
// Create a new patient
router.post("/", async (req: Request, res: Response): Promise<any> => {
try {

View File

@@ -138,6 +138,7 @@ export interface IStorage {
streamFileTo(resStream: NodeJS.WritableStream, fileId: number): Promise<void>;
// Patient folder
getPatientFolder(patientId: number): Promise<CloudFolder | null>;
getOrCreatePatientFolder(
userId: number,
patientId: number,
@@ -549,6 +550,13 @@ export const cloudStorageStorage: IStorage = {
},
// --- PATIENT FOLDER ---
async getPatientFolder(patientId: number) {
const existing = await db.cloudFolder.findFirst({
where: { patientId },
});
return (existing as unknown as CloudFolder) ?? null;
},
async getOrCreatePatientFolder(
userId: number,
patientId: number,

View File

@@ -0,0 +1,21 @@
import fs from "fs";
import path from "path";
// Local-disk-only debug copy of screenshots captured via the Copy Agent page.
// Not exposed anywhere in the app UI — purely for finding raw captures on disk when
// diagnosing AI detection issues. Never blocks the calling request if it fails.
const BACKUP_ROOT = path.join(process.cwd(), "uploads", "screenshot-backups");
export function backupScreenshots(files: { originalname: string; buffer: Buffer }[]): void {
try {
const dateDir = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const dir = path.join(BACKUP_ROOT, dateDir);
fs.mkdirSync(dir, { recursive: true });
for (const file of files) {
const safeName = `${Date.now()}_${file.originalname.replace(/[/\\?%*:|"<>]/g, "-")}`;
fs.writeFileSync(path.join(dir, safeName), file.buffer);
}
} catch (err) {
console.error("[screenshotBackup] failed to write backup:", err);
}
}

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>
)}

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>

View File

@@ -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 () => {