Files
DentalManagement09/apps/Frontend/src/pages/ai-copy-agent-page.tsx
T
Gitead 870bda5950 feat: chatbot screenshot-only eligibility detect, Sun Life/DentaQuest auto-check, chatbot attachment handoff to preauth; fix TuftsSCO preauth Selenium reliability
- 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
2026-07-25 00:10:05 -04:00

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 &amp; 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 &amp; Check Eligibility" to create one automatically.
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable
allowCheckbox={true}
onSelectPatient={setSelectedPatient}
/>
</CardContent>
</Card>
</div>
);
}