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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
69
apps/Frontend/src/hooks/use-screen-capture.ts
Normal file
69
apps/Frontend/src/hooks/use-screen-capture.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user