Compare commits
2
Commits
58d56c895b
...
f712479882
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f712479882 | ||
|
|
118315a04e |
+1
-1
@@ -2,7 +2,7 @@ NODE_ENV="development"
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
CLOUDFLARE_HOST=
|
||||
FRONTEND_URLS=http://localhost:3000
|
||||
FRONTEND_URLS=http://localhost:3000,https://local-summit.mydentalofficemanagement.com
|
||||
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||
JWT_SECRET = 'dentalsecret'
|
||||
LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92
|
||||
|
||||
@@ -12,6 +12,9 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Local HTTPS dev certs (machine-specific, generated via mkcert)
|
||||
certs/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
*/
|
||||
import { io, Socket } from "socket.io-client";
|
||||
|
||||
// Connect directly to backend to avoid Vite's WS proxy failing on upgrade,
|
||||
// which causes an unhandled AggregateError from engine.io's Promise.any() probe.
|
||||
// Use the env var when set; otherwise derive the backend URL from the current
|
||||
// page's hostname so remote browsers (non-localhost) reach the server correctly.
|
||||
// When served through nginx (any port other than Vite's own 3000), nginx already
|
||||
// proxies /socket.io/ with WS upgrade support — connect same-origin so TLS and
|
||||
// CORS are handled consistently with the rest of the app.
|
||||
// When hitting Vite's dev server directly on :3000, connect straight to the
|
||||
// backend instead, since Vite's own WS proxy fails on upgrade (unhandled
|
||||
// AggregateError from engine.io's Promise.any() probe).
|
||||
const SOCKET_URL =
|
||||
import.meta.env.VITE_API_BASE_URL_BACKEND ||
|
||||
`${window.location.protocol}//${window.location.hostname}:5000`;
|
||||
(window.location.port === "3000"
|
||||
? `${window.location.protocol}//${window.location.hostname}:5000`
|
||||
: window.location.origin);
|
||||
|
||||
export const socket: Socket = io(SOCKET_URL, {
|
||||
withCredentials: true,
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{cap.description}
|
||||
{/* 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>
|
||||
</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>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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"],
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
|
||||
allowedHosts: [
|
||||
...(env.VITE_CLOUDFLARE_HOST ? [env.VITE_CLOUDFLARE_HOST] : []),
|
||||
"192.168.0.94",
|
||||
"local-summit.mydentalofficemanagement.com",
|
||||
],
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
||||
+50
-2
@@ -1,6 +1,17 @@
|
||||
# ── LAN-only app access (staff) ──────────────────────────────────────────
|
||||
# https://local-summit.mydentalofficemanagement.com
|
||||
# DNS A record points at this office's private LAN IP; cert is a real
|
||||
# Let's Encrypt cert (issued via certbot + Cloudflare DNS-01), so no CA
|
||||
# needs to be installed on any staff PC. Restricted to the office subnet.
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
listen 443 ssl;
|
||||
server_name local-summit.mydentalofficemanagement.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/local-summit.mydentalofficemanagement.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/local-summit.mydentalofficemanagement.com/privkey.pem;
|
||||
|
||||
allow 192.168.0.0/24;
|
||||
deny all;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
@@ -33,3 +44,40 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
# ── Public Twilio webhooks only ──────────────────────────────────────────
|
||||
# https://summit.mydentalofficemanagement.com, reached via Cloudflare Tunnel.
|
||||
# Cloudflare terminates TLS at its edge and cloudflared forwards plain HTTP
|
||||
# to this block, so no certificate is needed here. Nothing except the
|
||||
# Twilio webhook path is exposed on this hostname.
|
||||
server {
|
||||
listen 80;
|
||||
server_name summit.mydentalofficemanagement.com;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /api/twilio/ {
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 403;
|
||||
}
|
||||
}
|
||||
|
||||
# Catch-all: reject anything not matching the two hostnames above
|
||||
# (e.g. plain http://<ip> or an unrecognized Host header).
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/local-summit.mydentalofficemanagement.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/local-summit.mydentalofficemanagement.com/privkey.pem;
|
||||
|
||||
return 403;
|
||||
}
|
||||
|
||||
Generated
+10
@@ -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