feat: add "Choose from Server" attachment picker to claim/preauth forms and chatbot

Native file inputs can't be pointed at a default server folder for security
reasons, so instead the Browse buttons on the Claim/PreAuth/Select Procedures
forms and the AI chatbot's attach button now also offer picking from
previously saved Cloud Storage attachments (e.g. Copy Agent screenshots) via
a new server-side picker modal, scoped per-patient on the claim forms and
unscoped (recent across all patients) in the chatbot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:36:08 -04:00
parent 8b245d00c6
commit 34312b62bc
6 changed files with 332 additions and 11 deletions

View File

@@ -158,6 +158,49 @@ router.post(
}
);
// GET /api/claims/patient-attachments?patientId=123
// Lists this patient's previously saved Cloud Storage "Attachments" (e.g. Copy Agent screenshots)
// so they can be picked and re-attached to a claim/preauth without re-uploading from disk.
router.get(
"/patient-attachments",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const patientId = Number(req.query.patientId);
if (!patientId || isNaN(patientId)) {
return res.status(400).json({ error: "Invalid patientId" });
}
try {
const files = await storage.listPatientCategoryCloudFiles(patientId, "Attachments");
return res.json({ error: false, data: files });
} catch (err: any) {
console.error("[patient-attachments]", err);
return res.status(500).json({ error: "Failed to list attachments", message: err?.message });
}
}
);
// GET /api/claims/recent-attachments?limit=20
// Lists recently saved Cloud Storage "Attachments" across all patients (e.g. Copy Agent
// screenshots), for use in contexts (like the AI chatbot) that don't have a patient in scope yet.
router.get(
"/recent-attachments",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const limit = Number(req.query.limit) || 20;
try {
const files = await storage.listRecentAttachmentFiles(limit);
return res.json({ error: false, data: files });
} catch (err: any) {
console.error("[recent-attachments]", err);
return res.status(500).json({ error: "Failed to list attachments", message: err?.message });
}
}
);
router.post(
"/mh-provider-login",
async (req: Request, res: Response): Promise<any> => {

View File

@@ -161,6 +161,7 @@ export interface IStorage {
patientId: number,
category: string
): Promise<CloudFile[]>;
listRecentAttachmentFiles(limit?: number): Promise<CloudFile[]>;
}
/* ------------------------------- Implementation ------------------------------- */
@@ -663,6 +664,25 @@ export const cloudStorageStorage: IStorage = {
return files.map(serializeFile) as unknown as CloudFile[];
},
async listRecentAttachmentFiles(limit: number = 20) {
const files = await db.cloudFile.findMany({
where: { folder: { name: "Attachments" }, isComplete: true },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
name: true,
mimeType: true,
fileSize: true,
folderId: true,
isComplete: true,
createdAt: true,
updatedAt: true,
},
});
return files.map(serializeFile) as unknown as CloudFile[];
},
// --- STREAM ---
async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) {
const file = await db.cloudFile.findUnique({

View File

@@ -2320,6 +2320,7 @@ export function ClaimForm({
isUploading={isUploading}
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
maxFiles={10}
patientId={patientId}
/>
{form.uploadedFiles.length > 0 && (
<ul className="text-sm text-gray-700 list-disc ml-6">
@@ -2883,6 +2884,7 @@ export function ClaimForm({
isUploading={isUploading}
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
maxFiles={10}
patientId={patientId}
/>
{form.uploadedFiles.length > 0 && (
<ul className="text-sm text-gray-700 list-disc ml-6">

View File

@@ -9,6 +9,7 @@ import { Upload, X, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
export type MultipleFileUploadZoneHandle = {
getFiles: () => File[];
@@ -25,6 +26,9 @@ interface FileUploadZoneProps {
maxFileSizeMB?: number;
//OPTIONAL: per-mime (or wildcard) map in MB: { "application/pdf": 10, "image/*": 2 }
maxFileSizeByType?: Record<string, number>;
// OPTIONAL: when set, shows a "Choose from Server" option listing this patient's
// previously saved Cloud Storage attachments (e.g. Copy Agent screenshots)
patientId?: number;
}
export const MultipleFileUploadZone = forwardRef<
@@ -39,12 +43,14 @@ export const MultipleFileUploadZone = forwardRef<
maxFiles = 10,
maxFileSizeMB = 10, // default fallback per-file size (MB)
maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 }
patientId,
},
ref
) => {
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const parsedAccept = acceptedFileTypes
@@ -222,7 +228,7 @@ export const MultipleFileUploadZone = forwardRef<
[onFilesChange]
);
const handleFiles = (files: FileList | null) => {
const handleFiles = (files: FileList | File[] | null) => {
if (!files) return;
const newFiles = Array.from(files).filter(validateFile);
@@ -439,19 +445,42 @@ export const MultipleFileUploadZone = forwardRef<
Or click to browse files
</p>
</div>
<Button
type="button"
variant="default"
onClick={(e) => {
e.stopPropagation();
handleBrowseClick();
}}
>
Browse files
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="default"
onClick={(e) => {
e.stopPropagation();
handleBrowseClick();
}}
>
Choose from This Computer
</Button>
{patientId ? (
<Button
type="button"
variant="outline"
onClick={(e) => {
e.stopPropagation();
setIsServerPickerOpen(true);
}}
>
Choose from Server
</Button>
) : null}
</div>
</div>
)}
</div>
{patientId ? (
<ServerAttachmentsPickerModal
open={isServerPickerOpen}
onClose={() => setIsServerPickerOpen(false)}
patientId={patientId}
onFilesSelected={(files) => handleFiles(files)}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,207 @@
import React, { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { FileText, File as FileIcon, Image as ImageIcon, Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import type { CloudFile } from "@repo/db/types";
interface ServerAttachmentsPickerModalProps {
open: boolean;
onClose: () => void;
// When set, lists only this patient's saved attachments. When omitted, lists
// recently saved attachments across all patients (used where no patient is in scope yet).
patientId?: number;
onFilesSelected: (files: File[]) => void;
}
function fileIcon(mime: string | undefined | null) {
if (mime?.startsWith("image/")) return ImageIcon;
if (mime === "application/pdf") return FileText;
return FileIcon;
}
function AttachmentThumbnail({ file }: { file: CloudFile }) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
if (!file.mimeType?.startsWith("image/")) return;
let objectUrl: string | null = null;
let cancelled = false;
(async () => {
const res = await apiRequest("GET", `/api/cloud-storage/files/${file.id}/content`);
if (!res.ok || cancelled) return;
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (!cancelled) setSrc(objectUrl);
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [file.id, file.mimeType]);
if (file.mimeType?.startsWith("image/")) {
return src ? (
<img src={src} alt={file.name} className="h-full w-full object-cover" />
) : (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
);
}
const Icon = fileIcon(file.mimeType);
return <Icon className="h-8 w-8 text-muted-foreground" />;
}
export function ServerAttachmentsPickerModal({
open,
onClose,
patientId,
onFilesSelected,
}: ServerAttachmentsPickerModalProps) {
const { toast } = useToast();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [isAdding, setIsAdding] = useState(false);
const endpoint = patientId
? `/api/claims/patient-attachments?patientId=${patientId}`
: `/api/claims/recent-attachments?limit=40`;
const { data, isLoading, isError } = useQuery<CloudFile[]>({
queryKey: ["/api/claims/attachments-picker", patientId ?? "recent"],
queryFn: async () => {
const res = await apiRequest("GET", endpoint);
const json = await res.json();
if (!res.ok || json.error) {
throw new Error(json.message || "Failed to load attachments");
}
return (json.data ?? []) as CloudFile[];
},
enabled: open,
});
useEffect(() => {
if (!open) setSelectedIds(new Set());
}, [open]);
if (!open) return null;
const files = data ?? [];
const toggleSelected = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddSelected = async () => {
if (selectedIds.size === 0) return;
setIsAdding(true);
try {
const selectedFiles = files.filter((f) => selectedIds.has(f.id as number));
const built = await Promise.all(
selectedFiles.map(async (f) => {
const res = await apiRequest("GET", `/api/cloud-storage/files/${f.id}/content`);
const blob = await res.blob();
return new File([blob], f.name, { type: f.mimeType || blob.type });
})
);
onFilesSelected(built);
onClose();
} catch (err) {
toast({
title: "Failed to add files",
description: "Could not fetch one or more attachments from the server.",
variant: "destructive",
});
} finally {
setIsAdding(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
if (!isAdding) onClose();
}}
/>
<div className="relative w-full max-w-2xl mx-4 bg-white rounded-lg shadow-lg flex flex-col max-h-[80vh]">
<div className="p-4 border-b">
<h3 className="text-lg font-medium">Choose from Server Attachments</h3>
<p className="text-sm text-muted-foreground mt-1">
{patientId
? "Previously saved screenshots and files for this patient."
: "Recently saved screenshots and files across all patients."}
</p>
</div>
<div className="p-4 overflow-y-auto flex-1" style={{ minHeight: "200px" }}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : isError ? (
<p className="text-sm text-red-500 text-center py-8">
Failed to load attachments.
</p>
) : files.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{patientId
? "No screenshots saved yet for this patient."
: "No screenshots saved yet."}
</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{files.map((file) => {
const isSelected = selectedIds.has(file.id as number);
return (
<button
type="button"
key={file.id}
onClick={() => toggleSelected(file.id as number)}
className={`relative border rounded-md overflow-hidden text-left ${
isSelected ? "ring-2 ring-primary border-primary" : "hover:bg-muted/40"
}`}
>
<div className="h-24 flex items-center justify-center bg-gray-50">
<AttachmentThumbnail file={file} />
</div>
<div className="p-2">
<p className="text-xs font-medium truncate">{file.name}</p>
</div>
{isSelected && (
<div className="absolute top-1 right-1 bg-primary text-primary-foreground rounded-full p-0.5">
<Check className="h-3 w-3" />
</div>
)}
</button>
);
})}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 p-4 border-t">
<Button variant="ghost" type="button" onClick={onClose} disabled={isAdding}>
Cancel
</Button>
<Button
type="button"
onClick={handleAddSelected}
disabled={selectedIds.size === 0 || isAdding}
>
{isAdding ? "Adding..." : `Add Selected (${selectedIds.size})`}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
Loader2,
RotateCcw,
Paperclip,
Image as ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -18,6 +19,7 @@ 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";
type Step =
| "menu"
@@ -204,6 +206,7 @@ export function ChatbotButton() {
renderingProvider: string | null;
} | null>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
const [, setLocation] = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(null);
@@ -1490,6 +1493,16 @@ export function ChatbotButton() {
>
<Paperclip 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="Attach screenshot"
onClick={() => setIsServerPickerOpen(true)}
disabled={step === "ai-loading"}
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
size="sm"
className="h-9 w-9 p-0 shrink-0"
@@ -1518,6 +1531,13 @@ export function ChatbotButton() {
e.target.value = "";
}}
/>
<ServerAttachmentsPickerModal
open={isServerPickerOpen}
onClose={() => setIsServerPickerOpen(false)}
onFilesSelected={(files) =>
setPendingFiles((prev) => [...prev, ...files].slice(0, 5))
}
/>
</div>
)}
</div>