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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user