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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
21
apps/Backend/src/utils/screenshotBackup.ts
Normal file
21
apps/Backend/src/utils/screenshotBackup.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 & 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 & 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user