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:
@@ -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> => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,6 +445,7 @@ export const MultipleFileUploadZone = forwardRef<
|
||||
Or click to browse files
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
@@ -447,11 +454,33 @@ export const MultipleFileUploadZone = forwardRef<
|
||||
handleBrowseClick();
|
||||
}}
|
||||
>
|
||||
Browse files
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user