- Chatbot: submitting with only a screenshot attached (no text) now triggers the same "AI Detect & Check Eligibility" flow as the Copy Agent page - detect-eligibility-info now also extracts the visible insurance payer name and picks Tufts SCO auto-check when both Sun Life and DentaQuest are detected, else MassHealth - Chatbot-attached files are now handed off to both claim and preauth forms unconditionally, not just claims - TuftsSCO preauth Selenium worker: verify-and-retry the Tooth field (typing was silently getting reset by duplicate-procedure-code warning banners), add a final re-verification pass across all rows, fix acknowledgement-checkbox targeting/verification, and verify the "Next step" click actually advances the wizard instead of trusting a blind click
604 lines
21 KiB
TypeScript
604 lines
21 KiB
TypeScript
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, 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";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import { PatientTable } from "@/components/patients/patient-table";
|
|
import { Patient } from "@repo/db/types";
|
|
|
|
function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
|
|
const scaleX = image.naturalWidth / image.width;
|
|
const scaleY = image.naturalHeight / image.height;
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = crop.width * scaleX;
|
|
canvas.height = crop.height * scaleY;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.drawImage(
|
|
image,
|
|
crop.x * scaleX,
|
|
crop.y * scaleY,
|
|
crop.width * scaleX,
|
|
crop.height * scaleY,
|
|
0,
|
|
0,
|
|
canvas.width,
|
|
canvas.height
|
|
);
|
|
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);
|
|
const [screenshot, setScreenshot] = useState<string | null>(null);
|
|
const [fileName, setFileName] = useState("");
|
|
const [crop, setCrop] = useState<Crop>();
|
|
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
|
|
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);
|
|
|
|
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
imgRef.current = e.currentTarget;
|
|
const { width, height } = e.currentTarget;
|
|
const fullCrop = centerCrop(
|
|
makeAspectCrop({ unit: "%", width: 100 }, width / height, width, height),
|
|
width,
|
|
height
|
|
);
|
|
setCrop(fullCrop);
|
|
setCompletedCrop({
|
|
unit: "px",
|
|
x: 0,
|
|
y: 0,
|
|
width,
|
|
height,
|
|
});
|
|
};
|
|
|
|
const stopStream = () => {
|
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
streamRef.current = null;
|
|
};
|
|
|
|
const handleCapture = async () => {
|
|
setIsCapturing(true);
|
|
try {
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { displaySurface: "monitor" },
|
|
});
|
|
streamRef.current = stream;
|
|
|
|
const video = document.createElement("video");
|
|
video.srcObject = stream;
|
|
video.muted = true;
|
|
await video.play();
|
|
await new Promise<void>((resolve) => {
|
|
if (video.readyState >= 2) resolve();
|
|
else video.onloadeddata = () => resolve();
|
|
});
|
|
|
|
for (let secondsLeft = 3; secondsLeft > 0; secondsLeft--) {
|
|
setCountdown(secondsLeft);
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
setCountdown(null);
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
stopStream();
|
|
setScreenshot(canvas.toDataURL("image/png"));
|
|
setFileName(`screenshot_${Date.now()}`);
|
|
} catch (error) {
|
|
console.error("Error capturing screenshot:", error);
|
|
const name = error instanceof Error ? error.name : "";
|
|
const description =
|
|
name === "NotAllowedError"
|
|
? "Screen share permission was denied or the picker was cancelled."
|
|
: name === "NotFoundError"
|
|
? "No screen/window source was available to capture."
|
|
: !navigator.mediaDevices?.getDisplayMedia
|
|
? "Screen capture isn't supported in this browser."
|
|
: (error instanceof Error ? error.message : "Screen capture failed.");
|
|
toast({
|
|
title: "Screenshot failed",
|
|
description,
|
|
variant: "destructive",
|
|
});
|
|
stopStream();
|
|
} finally {
|
|
setIsCapturing(false);
|
|
setCountdown(null);
|
|
}
|
|
};
|
|
|
|
const handleRetake = () => {
|
|
setScreenshot(null);
|
|
setFileName("");
|
|
setCrop(undefined);
|
|
setCompletedCrop(undefined);
|
|
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 (!selectedPatient) return;
|
|
const items = getPendingItems();
|
|
if (!items.length) return;
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
const patientName = getPatientName(selectedPatient);
|
|
const files = await itemsToFiles(items);
|
|
await uploadToPatient(selectedPatient.id, patientName, files, false);
|
|
|
|
toast({
|
|
title: "Saved",
|
|
description: `${items.length} screenshot${items.length > 1 ? "s" : ""} saved to ${patientName}'s attachments.`,
|
|
});
|
|
setBatch([]);
|
|
clearActive();
|
|
} catch (error) {
|
|
console.error("Error saving screenshot:", error);
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to save screenshots to patient attachments.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
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;
|
|
const autoCheck: string = detectResult.value?.data?.autoCheck ?? "mh";
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent("chatbot:open-eligibility-id-ready", {
|
|
detail: {
|
|
memberId,
|
|
dob,
|
|
autoCheck,
|
|
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; autoCheck: string };
|
|
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, autoCheck: json?.data?.autoCheck ?? "mh" };
|
|
} 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, autoCheck } = 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,
|
|
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, patient: null },
|
|
})
|
|
);
|
|
}
|
|
} finally {
|
|
setIsDetecting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto space-y-6">
|
|
|
|
{/* Hero */}
|
|
<div className="text-center space-y-3">
|
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-violet-100 mb-2">
|
|
<Copy className="h-8 w-8 text-violet-600" />
|
|
</div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Copy Agent</h1>
|
|
<p className="text-muted-foreground text-base max-w-xl mx-auto">
|
|
An AI agent reads and copies data including images and brings them to this app
|
|
automatically. No manual input required.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Screenshot capture */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Capture Screenshot</CardTitle>
|
|
<CardDescription>
|
|
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
|
|
crop={crop}
|
|
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
|
onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)}
|
|
>
|
|
<img
|
|
src={screenshot}
|
|
alt="Captured screenshot"
|
|
onLoad={handleImageLoad}
|
|
className="w-full rounded-lg border object-contain max-h-96"
|
|
/>
|
|
</ReactCrop>
|
|
<p className="text-xs text-muted-foreground">
|
|
Drag the corners to crop, or leave as-is to save the full screenshot.
|
|
</p>
|
|
<div className="space-y-1.5 max-w-sm">
|
|
<Label htmlFor="screenshot-filename">File name</Label>
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
id="screenshot-filename"
|
|
value={fileName}
|
|
onChange={(e) => setFileName(e.target.value)}
|
|
placeholder="screenshot"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">.png</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button variant="outline" onClick={handleRetake} disabled={isCapturing || isSaving}>
|
|
<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 || isDetecting}
|
|
title={!selectedPatient ? "Select a patient below first" : undefined}
|
|
>
|
|
<Save className="h-4 w-4 mr-2" />
|
|
{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 Save All, or use "AI Detect & Check
|
|
Eligibility" without selecting one to create a patient automatically.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<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>
|
|
|
|
{/* Patient search */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Patient Records</CardTitle>
|
|
<CardDescription>
|
|
Select the patient this screenshot belongs to, or leave unselected and use
|
|
"AI Detect & Check Eligibility" to create one automatically.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<PatientTable
|
|
allowCheckbox={true}
|
|
onSelectPatient={setSelectedPatient}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</div>
|
|
);
|
|
}
|