From c95d08c951a890c45862135193cd616d093f334f Mon Sep 17 00:00:00 2001 From: Gitead Date: Thu, 16 Jul 2026 21:20:33 -0400 Subject: [PATCH] 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-" 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 --- .../queue/processors/eligibilityProcessor.ts | 16 +- apps/Backend/src/routes/ai-settings.ts | 79 +++- apps/Backend/src/routes/claims.ts | 39 +- apps/Backend/src/routes/patients.ts | 33 ++ .../src/storage/cloudStorage-storage.ts | 8 + apps/Backend/src/utils/screenshotBackup.ts | 21 + .../src/components/layout/chatbot.tsx | 126 +++++- .../Frontend/src/pages/ai-copy-agent-page.tsx | 401 ++++++++++++++++-- .../src/pages/insurance-status-page.tsx | 82 +++- 9 files changed, 748 insertions(+), 57 deletions(-) create mode 100644 apps/Backend/src/utils/screenshotBackup.ts diff --git a/apps/Backend/src/queue/processors/eligibilityProcessor.ts b/apps/Backend/src/queue/processors/eligibilityProcessor.ts index e5598c0f..1cb926d5 100644 --- a/apps/Backend/src/queue/processors/eligibilityProcessor.ts +++ b/apps/Backend/src/queue/processors/eligibilityProcessor.ts @@ -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-") 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, diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 948e5d91..008a73b7 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -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 } }); +// 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 => { + 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> = [ + { + 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 => { try { const userId = req.user?.id; diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index d552674a..5125cf71 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -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 => { + 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. diff --git a/apps/Backend/src/routes/patients.ts b/apps/Backend/src/routes/patients.ts index be1b5323..237a2fb6 100755 --- a/apps/Backend/src/routes/patients.ts +++ b/apps/Backend/src/routes/patients.ts @@ -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-" — 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 => { + 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 => { try { diff --git a/apps/Backend/src/storage/cloudStorage-storage.ts b/apps/Backend/src/storage/cloudStorage-storage.ts index 378bfe1f..35426936 100755 --- a/apps/Backend/src/storage/cloudStorage-storage.ts +++ b/apps/Backend/src/storage/cloudStorage-storage.ts @@ -138,6 +138,7 @@ export interface IStorage { streamFileTo(resStream: NodeJS.WritableStream, fileId: number): Promise; // Patient folder + getPatientFolder(patientId: number): Promise; 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, diff --git a/apps/Backend/src/utils/screenshotBackup.ts b/apps/Backend/src/utils/screenshotBackup.ts new file mode 100644 index 00000000..a62213cf --- /dev/null +++ b/apps/Backend/src/utils/screenshotBackup.ts @@ -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); + } +} diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index 5ded4b28..d382b422 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -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(null); const freeTextRef = useRef(null); const fileInputRef = useRef(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 => { + 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}

)} -

ID: {eligibilityIdData.memberId}

-

DOB: {eligibilityIdData.dob}

+
+ + + setEligibilityIdData((prev) => (prev ? { ...prev, memberId: e.target.value } : prev)) + } + className="h-7 text-xs bg-white" + /> +
+
+ + + setEligibilityIdData((prev) => + prev ? { ...prev, dob: mdyToISO(e.target.value) } : prev + ) + } + placeholder="MM/DD/YYYY" + className="h-7 text-xs bg-white" + /> +
{eligibilityIdData.patient?.insuranceProvider && (

{eligibilityIdData.patient.insuranceProvider}

)} diff --git a/apps/Frontend/src/pages/ai-copy-agent-page.tsx b/apps/Frontend/src/pages/ai-copy-agent-page.tsx index 031c7cb3..17ba35a3 100644 --- a/apps/Frontend/src/pages/ai-copy-agent-page.tsx +++ b/apps/Frontend/src/pages/ai-copy-agent-page.tsx @@ -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(null); @@ -43,6 +57,8 @@ export default function AiCopyAgentPage() { const [isCapturing, setIsCapturing] = useState(false); const [countdown, setCountdown] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [isDetecting, setIsDetecting] = useState(false); + const [batch, setBatch] = useState([]); const streamRef = useRef(null); const imgRef = useRef(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 => { + 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-" 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 (
@@ -200,10 +453,34 @@ export default function AiCopyAgentPage() { Capture Screenshot - 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. + {batch.length > 0 && ( +
+ {batch.map((item) => ( +
+ {item.fileName} + +
+ ))} +
+ )} + {screenshot ? (
Retake + +
{!selectedPatient && (

- Select a patient below to enable saving. + Select a patient below to enable Save All, or use "AI Detect & Check + Eligibility" without selecting one to create a patient automatically.

)}
) : ( - +
+ + {batch.length > 0 && ( + <> + + + + )} +
)} @@ -271,7 +585,8 @@ export default function AiCopyAgentPage() { Patient Records - Select the patient this screenshot belongs to. + Select the patient this screenshot belongs to, or leave unselected and use + "AI Detect & Check Eligibility" to create one automatically. diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 65b185e5..74095db5 100755 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -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 => { 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 () => {