fix: skip appointment-file merge for chatbot claims; add live screenshot capture to chatbot

- selenium-claim route no longer re-appends previously saved appointment
  attachments when the chatbot's autoSubmit flow already sent every intended
  file, preventing an unwanted extra upload (e.g. attach 2 -> Selenium sees 3).
- Add a "Take screenshot" button to the AI chatbot's attachment bar, reusing
  the same capture/crop flow as the appointment editor, so claims and
  preauths can be submitted from the chatbot with a live screenshot attached.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 22:31:34 -04:00
parent a42ef5f570
commit 6b9b3c41f2
3 changed files with 161 additions and 2 deletions

View File

@@ -348,7 +348,9 @@ router.post(
// Include attachments already saved on the appointment (e.g. via "Select Procedures")
// that weren't re-uploaded in this request — read straight from disk by filePath.
if (claimData.appointmentId) {
// Skipped for chatbot-driven submissions, which already send every intended file and
// shouldn't have unrelated older appointment attachments silently tacked on.
if (claimData.appointmentId && !claimData.skipAppointmentAttachmentMerge) {
const seenNames = new Set(filesForQueue.map((f) => f.originalname));
const apptFiles = await storage.getAppointmentFiles(Number(claimData.appointmentId));
for (const f of apptFiles) {

View File

@@ -1021,6 +1021,10 @@ export function ClaimForm({
insuranceSiteKey: "MH",
claimId: createdClaim.id,
uploadedFiles,
// Chatbot-driven submissions already include every file the user intends to send —
// skip the backend's appointment-attachment fallback merge so old files saved on the
// appointment (e.g. via the Schedule editor) aren't silently added as extra uploads.
skipAppointmentAttachmentMerge: !!autoSubmit,
});
// 5. Close form

View File

@@ -12,7 +12,11 @@ import {
RotateCcw,
Paperclip,
Image as ImageIcon,
Camera,
Check,
} from "lucide-react";
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -23,11 +27,41 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useLocation } from "wouter";
import { cn } from "@/lib/utils";
import { apiRequest } from "@/lib/queryClient";
import { setChatbotPendingFiles } from "@/lib/chatbotFileStore";
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
import { useScreenCapture } from "@/hooks/use-screen-capture";
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");
}
type Step =
| "menu"
@@ -242,6 +276,61 @@ export function ChatbotButton() {
} | null>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
const { capture, isCapturing, countdown } = useScreenCapture();
const [screenshotDataUrl, setScreenshotDataUrl] = useState<string | null>(null);
const [screenshotFileName, setScreenshotFileName] = useState("");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const screenshotImgRef = useRef<HTMLImageElement | null>(null);
const closeScreenshotDialog = () => {
setScreenshotDataUrl(null);
setScreenshotFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
};
const handleScreenshotImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
screenshotImgRef.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 handleTakeScreenshot = async () => {
try {
const dataUrl = await capture();
setScreenshotDataUrl(dataUrl);
setScreenshotFileName(`screenshot_${Date.now()}`);
} catch (err: any) {
addMsg("bot", err?.message ?? "Screen capture failed.");
}
};
const handleRetakeScreenshot = async () => {
closeScreenshotDialog();
await handleTakeScreenshot();
};
const handleAttachScreenshot = async () => {
if (!screenshotDataUrl) return;
const croppedDataUrl =
completedCrop && screenshotImgRef.current
? getCroppedDataUrl(screenshotImgRef.current, completedCrop)
: screenshotDataUrl;
const response = await fetch(croppedDataUrl);
const blob = await response.blob();
const safeName =
screenshotFileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
const file = new File([blob], `${safeName}.png`, { type: "image/png" });
closeScreenshotDialog();
setPendingFiles((prev) => [...prev, file].slice(0, 5));
};
const [, setLocation] = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(null);
@@ -1670,12 +1759,28 @@ export function ChatbotButton() {
size="sm"
variant="ghost"
className="h-9 w-9 p-0 shrink-0 text-gray-400 hover:text-gray-600"
title="Attach screenshot"
title="Choose from scanned attachments"
onClick={() => setIsServerPickerOpen(true)}
disabled={step === "ai-loading"}
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-9 w-9 p-0 shrink-0 text-gray-400 hover:text-gray-600"
title="Take screenshot"
onClick={handleTakeScreenshot}
disabled={step === "ai-loading" || isCapturing}
>
{isCapturing ? (
<span className="flex h-4 w-4 items-center justify-center text-xs">
{countdown ?? <Loader2 className="h-4 w-4 animate-spin" />}
</span>
) : (
<Camera className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
className="h-9 w-9 p-0 shrink-0"
@@ -1711,6 +1816,54 @@ export function ChatbotButton() {
setPendingFiles((prev) => [...prev, ...files].slice(0, 5))
}
/>
<Dialog open={!!screenshotDataUrl} onOpenChange={(open) => !open && closeScreenshotDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Crop screenshot</DialogTitle>
</DialogHeader>
{screenshotDataUrl && (
<div className="space-y-3">
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)}
>
<img
src={screenshotDataUrl}
alt="Captured screenshot"
onLoad={handleScreenshotImageLoad}
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 attach the full screenshot.
</p>
<div className="space-y-1.5">
<Label htmlFor="chatbot-screenshot-filename">File name</Label>
<div className="flex items-center gap-1">
<Input
id="chatbot-screenshot-filename"
value={screenshotFileName}
onChange={(e) => setScreenshotFileName(e.target.value)}
placeholder="screenshot"
/>
<span className="text-sm text-muted-foreground">.png</span>
</div>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleRetakeScreenshot} disabled={isCapturing}>
<RotateCcw className="h-4 w-4 mr-2" />
Retake
</Button>
<Button type="button" onClick={handleAttachScreenshot}>
<Check className="h-4 w-4 mr-2" />
Attach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</div>