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:
@@ -348,7 +348,9 @@ router.post(
|
|||||||
|
|
||||||
// Include attachments already saved on the appointment (e.g. via "Select Procedures")
|
// 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.
|
// 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 seenNames = new Set(filesForQueue.map((f) => f.originalname));
|
||||||
const apptFiles = await storage.getAppointmentFiles(Number(claimData.appointmentId));
|
const apptFiles = await storage.getAppointmentFiles(Number(claimData.appointmentId));
|
||||||
for (const f of apptFiles) {
|
for (const f of apptFiles) {
|
||||||
|
|||||||
@@ -1021,6 +1021,10 @@ export function ClaimForm({
|
|||||||
insuranceSiteKey: "MH",
|
insuranceSiteKey: "MH",
|
||||||
claimId: createdClaim.id,
|
claimId: createdClaim.id,
|
||||||
uploadedFiles,
|
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
|
// 5. Close form
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Camera,
|
||||||
|
Check,
|
||||||
} from "lucide-react";
|
} 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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -23,11 +27,41 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { setChatbotPendingFiles } from "@/lib/chatbotFileStore";
|
import { setChatbotPendingFiles } from "@/lib/chatbotFileStore";
|
||||||
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
|
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 =
|
type Step =
|
||||||
| "menu"
|
| "menu"
|
||||||
@@ -242,6 +276,61 @@ export function ChatbotButton() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
|
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 [, setLocation] = useLocation();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -1670,12 +1759,28 @@ export function ChatbotButton() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-9 w-9 p-0 shrink-0 text-gray-400 hover:text-gray-600"
|
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)}
|
onClick={() => setIsServerPickerOpen(true)}
|
||||||
disabled={step === "ai-loading"}
|
disabled={step === "ai-loading"}
|
||||||
>
|
>
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-9 w-9 p-0 shrink-0"
|
className="h-9 w-9 p-0 shrink-0"
|
||||||
@@ -1711,6 +1816,54 @@ export function ChatbotButton() {
|
|||||||
setPendingFiles((prev) => [...prev, ...files].slice(0, 5))
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user