feat: add screenshot capture to Copy Agent page

Staff can now capture their screen via the browser's Screen Capture
API, crop the result, name the file, and save it directly to a
selected patient's Cloud Storage attachments folder.
This commit is contained in:
2026-07-12 17:25:12 -04:00
parent 58d56c895b
commit 118315a04e
4 changed files with 272 additions and 105 deletions

View File

@@ -76,6 +76,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-image-crop": "^11.1.2",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"socket.io-client": "^4.8.1",

View File

@@ -1,30 +1,187 @@
import { Copy, FileCheck, CreditCard, Shield, Zap } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
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 { 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";
const CAPABILITIES = [
{
icon: <Shield className="h-5 w-5 text-teal-600" />,
title: "Eligibility Results",
description:
"Automatically read insurance eligibility information — coverage status, plan details, deductibles, and co-pays — from the insurance portal.",
},
{
icon: <FileCheck className="h-5 w-5 text-blue-600" />,
title: "Claim Information",
description:
"Extract claim numbers, claim status updates, and denial reasons from the insurance portal so they can be reused elsewhere without manual re-entry.",
},
{
icon: <CreditCard className="h-5 w-5 text-indigo-600" />,
title: "Insurance Payments",
description:
"Capture ERA / EOB payment details — amounts, adjustments, patient responsibility — straight from the source document or portal.",
},
];
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");
}
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 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 handleSave = async () => {
if (!screenshot || !selectedPatient) 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);
toast({
title: "Saved",
description: `Screenshot saved to ${patientName}'s attachments.`,
});
setScreenshot(null);
setFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
} catch (error) {
console.error("Error saving screenshot:", error);
toast({
title: "Error",
description: "Failed to save screenshot to patient attachments.",
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
return (
<div className="max-w-3xl mx-auto px-4 py-10 space-y-10">
<div className="container mx-auto space-y-6">
{/* Hero */}
<div className="text-center space-y-3">
@@ -36,43 +193,92 @@ export default function AiCopyAgentPage() {
An AI agent reads and copies data including images and brings them to this app
automatically. No manual input required.
</p>
<span className="inline-block bg-amber-100 text-amber-700 text-xs font-semibold px-3 py-1 rounded-full border border-amber-200">
Coming Soon
</span>
</div>
{/* What it will copy */}
<div className="space-y-3">
<h2 className="text-base font-semibold">What it will copy</h2>
<div className="grid gap-4 sm:grid-cols-3">
{CAPABILITIES.map((cap) => (
<Card key={cap.title}>
<CardContent className="py-5 space-y-2">
<div className="flex items-center gap-2">
{cap.icon}
<span className="text-sm font-medium">{cap.title}</span>
{/* Screenshot capture */}
<Card>
<CardHeader>
<CardTitle>Capture Screenshot</CardTitle>
<CardDescription>
Capture your screen and save it to a patient's attachments.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{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>
<p className="text-xs text-muted-foreground leading-relaxed">
{cap.description}
</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
onClick={handleSave}
disabled={!selectedPatient || isSaving}
title={!selectedPatient ? "Select a patient below first" : undefined}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : "Save to Patient"}
</Button>
</div>
{!selectedPatient && (
<p className="text-xs text-muted-foreground">
Select a patient below to enable saving.
</p>
</CardContent>
</Card>
))}
</div>
</div>
)}
</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>
)}
</CardContent>
</Card>
{/* Why */}
<Card className="border-violet-200 bg-violet-50/50">
<CardContent className="py-5 flex items-start gap-3">
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-violet-900">Why this matters</p>
<p className="text-sm text-violet-700 leading-relaxed">
Staff currently check eligibility or look up a claim on an insurance portal, then
manually copy every value into this app. This agent eliminates that step entirely
one click pulls the data straight in.
</p>
</div>
{/* Patient search */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select the patient this screenshot belongs to.
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable
allowCheckbox={true}
onSelectPatient={setSelectedPatient}
/>
</CardContent>
</Card>

View File

@@ -1,50 +0,0 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
server: {
host: env.HOST,
port: Number(env.PORT),
fs: {
allow: [".."],
},
allowedHosts: [
...(env.VITE_CLOUDFLARE_HOST ? [env.VITE_CLOUDFLARE_HOST] : []),
"192.168.0.94",
],
proxy: {
"/api": {
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
changeOrigin: true,
configure: (proxy) => {
proxy.on("proxyReq", (proxyReq, req) => {
const auth = req.headers["authorization"];
if (auth)
proxyReq.setHeader("Authorization", auth);
});
},
},
"/socket.io": {
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
changeOrigin: true,
ws: true,
},
},
},
resolve: {
extensions: [".mts", ".ts", ".tsx", ".mjs", ".js", ".jsx", ".json"],
alias: {
"@": path.resolve(__dirname, "src"),
"@repo/db/usedSchemas": path.resolve(__dirname, "../../packages/db/usedSchemas/browser.ts"),
"@repo/db/types": path.resolve(__dirname, "../../packages/db/types/index.ts"),
"@repo/db": path.resolve(__dirname, "../../packages/db"),
},
},
optimizeDeps: {
exclude: ["@repo/db"],
},
};
});

10
package-lock.json generated
View File

@@ -150,6 +150,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-image-crop": "^11.1.2",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"socket.io-client": "^4.8.1",
@@ -11994,6 +11995,15 @@
"react": "*"
}
},
"node_modules/react-image-crop": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz",
"integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==",
"license": "ISC",
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",