feat: add attachment upload to Edit Appointment form (disk, scanned, screenshot with crop)

Lets staff attach files to an appointment directly from the edit form via
local disk upload, picking a previously-saved scanned attachment, or an
inline screenshot with crop-before-save (matching the Copy Agent page's
crop UX), instead of detouring through the Copy Agent page. Also fixes a
latent data-loss bug in claim-form's save-for-appointment call, which
replaced all AppointmentFile rows instead of merging with existing ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 14:55:38 -04:00
parent f29064883c
commit e49dfdbee9
6 changed files with 499 additions and 2 deletions

View File

@@ -0,0 +1,304 @@
import { useRef, useState } from "react";
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { Paperclip, Image as ImageIcon, Camera, Loader2, Check, RotateCcw, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { useScreenCapture } from "@/hooks/use-screen-capture";
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface AppointmentAttachmentsProps {
appointmentId: number;
patientId?: number;
}
interface AttachmentItem {
localId: string;
filename: string;
status: "uploading" | "done" | "error";
file: File;
}
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");
}
async function uploadAppointmentAttachment(
appointmentId: number,
patientId: number,
file: File
) {
const formData = new FormData();
formData.append("file", file);
formData.append("patientId", String(patientId));
const res = await apiRequest("POST", `/api/appointments/${appointmentId}/files`, formData);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message || body?.error || "Failed to upload attachment");
}
return res.json();
}
export function AppointmentAttachments({ appointmentId, patientId }: AppointmentAttachmentsProps) {
const [items, setItems] = useState<AttachmentItem[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { capture, isCapturing, countdown } = useScreenCapture();
// Crop dialog state, shown after a screenshot is captured
const [screenshotDataUrl, setScreenshotDataUrl] = useState<string | null>(null);
const [screenshotFileName, setScreenshotFileName] = useState("");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
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 closeCropDialog = () => {
setScreenshotDataUrl(null);
setScreenshotFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
};
const runUpload = async (file: File) => {
if (!patientId) {
toast({ title: "Missing patient", description: "Cannot attach files without a patient.", variant: "destructive" });
return;
}
const localId = crypto.randomUUID();
setItems((prev) => [...prev, { localId, filename: file.name, status: "uploading", file }]);
try {
await uploadAppointmentAttachment(appointmentId, patientId, file);
setItems((prev) => prev.map((it) => (it.localId === localId ? { ...it, status: "done" } : it)));
} catch (err: any) {
setItems((prev) => prev.map((it) => (it.localId === localId ? { ...it, status: "error" } : it)));
toast({
title: "Upload failed",
description: err?.message ?? `Failed to upload ${file.name}.`,
variant: "destructive",
});
}
};
const retryUpload = (item: AttachmentItem) => {
setItems((prev) => prev.filter((it) => it.localId !== item.localId));
void runUpload(item.file);
};
const handleDiskFilesSelected = (files: FileList | null) => {
if (!files?.length) return;
Array.from(files).forEach((file) => void runUpload(file));
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleServerFilesSelected = (files: File[]) => {
setPickerOpen(false);
files.forEach((file) => void runUpload(file));
};
const handleScreenshot = async () => {
try {
const dataUrl = await capture();
setScreenshotDataUrl(dataUrl);
setScreenshotFileName(`screenshot_${Date.now()}`);
} catch (err: any) {
toast({
title: "Screenshot failed",
description: err?.message ?? "Screen capture failed.",
variant: "destructive",
});
}
};
const handleRetakeScreenshot = async () => {
closeCropDialog();
await handleScreenshot();
};
const handleSaveScreenshot = async () => {
if (!screenshotDataUrl) return;
const croppedDataUrl =
completedCrop && imgRef.current
? getCroppedDataUrl(imgRef.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" });
closeCropDialog();
void runUpload(file);
};
return (
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Attachments</span>
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
title="Upload from disk"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
title="Choose from scanned attachments"
onClick={() => setPickerOpen(true)}
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
title="Take screenshot"
disabled={isCapturing}
onClick={handleScreenshot}
>
{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>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,application/pdf"
className="hidden"
onChange={(e) => handleDiskFilesSelected(e.target.files)}
/>
</div>
{items.length > 0 && (
<ul className="space-y-1">
{items.map((item) => (
<li key={item.localId} className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.filename}</span>
{item.status === "uploading" && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />}
{item.status === "done" && <Check className="h-3.5 w-3.5 shrink-0 text-green-600" />}
{item.status === "error" && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
title="Retry upload"
onClick={() => retryUpload(item)}
>
<RotateCcw className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</li>
))}
</ul>
)}
<ServerAttachmentsPickerModal
open={pickerOpen}
onClose={() => setPickerOpen(false)}
patientId={patientId}
onFilesSelected={handleServerFilesSelected}
/>
<Dialog open={!!screenshotDataUrl} onOpenChange={(open) => !open && closeCropDialog()}>
<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={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 attach the full screenshot.
</p>
<div className="space-y-1.5">
<Label htmlFor="appointment-screenshot-filename">File name</Label>
<div className="flex items-center gap-1">
<Input
id="appointment-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={handleSaveScreenshot}>
<Check className="h-4 w-4 mr-2" />
Attach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -37,6 +37,7 @@ import {
import { DateInputField } from "@/components/ui/dateInputField";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { toast } from "@/hooks/use-toast";
import { AppointmentAttachments } from "@/components/appointments/appointment-attachments";
export interface NewAppointmentPrefill {
staffId: number;
@@ -633,6 +634,10 @@ export function AppointmentForm({
)}
/>
{appointment?.id && (
<AppointmentAttachments appointmentId={appointment.id} patientId={appointment.patientId} />
)}
<Button type="submit" disabled={isLoading} className="w-full">
{appointment ? "Update Appointment" : "Create Appointment"}
</Button>

View File

@@ -494,6 +494,10 @@ export function ClaimForm({
totalPaid: new Decimal(0),
}));
if (data.appointmentFiles?.length) {
setExistingAppointmentFiles(data.appointmentFiles);
}
setForm((prev) => ({
...prev,
serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines,
@@ -900,6 +904,9 @@ export function ClaimForm({
const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [priceMismatches, setPriceMismatches] = useState<PriceMismatch[]>([]);
// AppointmentFile rows already attached to this appointment (from prefill), kept separate
// from claimFiles/uploadedFiles so handleProceduresSave can merge rather than replace them.
const [existingAppointmentFiles, setExistingAppointmentFiles] = useState<ClaimFileMeta[]>([]);
const pendingClaimAction = useRef<(() => void) | null>(null);
// NO validation here — the upload zone handles validation, toasts, max files, sizes, etc.
@@ -1617,10 +1624,17 @@ export function ClaimForm({
: null;
try {
const attachments = form.uploadedFiles?.length
const newAttachments = form.uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(form.uploadedFiles)
: [];
// save-for-appointment replaces ALL AppointmentFile rows for this appointment, so we must
// include files already attached (e.g. via the appointment edit form) or they'd be wiped.
const byFilename = new Map<string, ClaimFileMeta>();
for (const f of existingAppointmentFiles) byFilename.set(f.filename, f);
for (const f of newAttachments) byFilename.set(f.filename, f);
const attachments = Array.from(byFilename.values());
const res = await apiRequest("POST", "/api/appointment-procedures/save-for-appointment", {
appointmentId,
patientId,
@@ -1649,7 +1663,7 @@ export function ClaimForm({
}
}
const attachMsg = attachments.length ? ` and ${attachments.length} attachment(s)` : "";
const attachMsg = newAttachments.length ? ` and ${newAttachments.length} attachment(s)` : "";
const typeMsg = inferredType ? ` · Type → ${getAppointmentTypeLabel(inferredType)}` : "";
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved${typeMsg}.` });
onClose();

View File

@@ -0,0 +1,69 @@
import { useRef, useState } from "react";
// Minimal single-shot screen capture, extracted from the batching/cropping logic in
// ai-copy-agent-page.tsx — this just needs "take a screenshot, attach it."
export function useScreenCapture() {
const [isCapturing, setIsCapturing] = useState(false);
const [countdown, setCountdown] = useState<number | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const stopStream = () => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
};
// Resolves with a PNG data URL of the full screenshot (caller crops before converting to a File).
async function capture(): Promise<string> {
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();
});
// Short countdown so the user has time to alt-tab to the window they want to capture.
for (let secondsLeft = 2; 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;
canvas.getContext("2d")?.drawImage(video, 0, 0, canvas.width, canvas.height);
stopStream();
return canvas.toDataURL("image/png");
} catch (error) {
stopStream();
const name = error instanceof Error ? error.name : "";
const message =
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.";
throw new Error(message);
} finally {
setIsCapturing(false);
setCountdown(null);
}
}
return { capture, isCapturing, countdown };
}