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-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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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-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",
|
||||||
|
|||||||
Reference in New Issue
Block a user