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