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-dom": "^19.1.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-image-crop": "^11.1.2",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",

View File

@@ -1,30 +1,187 @@
import { Copy, FileCheck, CreditCard, Shield, Zap } from "lucide-react"; import { useRef, useState } from "react";
import { Card, CardContent } from "@/components/ui/card"; 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 = [ function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
{ const scaleX = image.naturalWidth / image.width;
icon: <Shield className="h-5 w-5 text-teal-600" />, const scaleY = image.naturalHeight / image.height;
title: "Eligibility Results",
description: const canvas = document.createElement("canvas");
"Automatically read insurance eligibility information — coverage status, plan details, deductibles, and co-pays — from the insurance portal.", canvas.width = crop.width * scaleX;
}, canvas.height = crop.height * scaleY;
{ const ctx = canvas.getContext("2d");
icon: <FileCheck className="h-5 w-5 text-blue-600" />, ctx?.drawImage(
title: "Claim Information", image,
description: crop.x * scaleX,
"Extract claim numbers, claim status updates, and denial reasons from the insurance portal so they can be reused elsewhere without manual re-entry.", crop.y * scaleY,
}, crop.width * scaleX,
{ crop.height * scaleY,
icon: <CreditCard className="h-5 w-5 text-indigo-600" />, 0,
title: "Insurance Payments", 0,
description: canvas.width,
"Capture ERA / EOB payment details — amounts, adjustments, patient responsibility — straight from the source document or portal.", canvas.height
}, );
]; return canvas.toDataURL("image/png");
}
export default function AiCopyAgentPage() { 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 ( return (
<div className="max-w-3xl mx-auto px-4 py-10 space-y-10"> <div className="container mx-auto space-y-6">
{/* Hero */} {/* Hero */}
<div className="text-center space-y-3"> <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 An AI agent reads and copies data including images and brings them to this app
automatically. No manual input required. automatically. No manual input required.
</p> </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> </div>
{/* What it will copy */} {/* Screenshot capture */}
<div className="space-y-3"> <Card>
<h2 className="text-base font-semibold">What it will copy</h2> <CardHeader>
<div className="grid gap-4 sm:grid-cols-3"> <CardTitle>Capture Screenshot</CardTitle>
{CAPABILITIES.map((cap) => ( <CardDescription>
<Card key={cap.title}> Capture your screen and save it to a patient's attachments.
<CardContent className="py-5 space-y-2"> </CardDescription>
<div className="flex items-center gap-2"> </CardHeader>
{cap.icon} <CardContent className="space-y-4">
<span className="text-sm font-medium">{cap.title}</span> {screenshot ? (
</div> <div className="space-y-4">
<p className="text-xs text-muted-foreground leading-relaxed"> <ReactCrop
{cap.description} 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> </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
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>
)}
</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> </CardContent>
</Card> </Card>
))}
</div>
</div>
{/* Why */} {/* Patient search */}
<Card className="border-violet-200 bg-violet-50/50"> <Card>
<CardContent className="py-5 flex items-start gap-3"> <CardHeader>
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0" /> <CardTitle>Patient Records</CardTitle>
<div className="space-y-1"> <CardDescription>
<p className="text-sm font-medium text-violet-900">Why this matters</p> Select the patient this screenshot belongs to.
<p className="text-sm text-violet-700 leading-relaxed"> </CardDescription>
Staff currently check eligibility or look up a claim on an insurance portal, then </CardHeader>
manually copy every value into this app. This agent eliminates that step entirely <CardContent>
one click pulls the data straight in. <PatientTable
</p> allowCheckbox={true}
</div> onSelectPatient={setSelectedPatient}
/>
</CardContent> </CardContent>
</Card> </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-dom": "^19.1.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-image-crop": "^11.1.2",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
@@ -11994,6 +11995,15 @@
"react": "*" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",