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:
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import multer from "multer";
|
||||||
import {
|
import {
|
||||||
insertAppointmentSchema,
|
insertAppointmentSchema,
|
||||||
updateAppointmentSchema,
|
updateAppointmentSchema,
|
||||||
@@ -9,6 +10,20 @@ import {
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// Reuses the same memory-storage + type/size limits as claims.ts's upload-to-cloud route
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit per file
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
|
||||||
|
if (allowed.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error("Unsupported file type"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mirrors the same logic in claims.ts and appointmentTypeUtils.ts
|
// Mirrors the same logic in claims.ts and appointmentTypeUtils.ts
|
||||||
function inferApptType(codes: string[]): string | null {
|
function inferApptType(codes: string[]): string | null {
|
||||||
const priority = ["endo","implant","crown","pedo","dentures","extraction","perio","filling","ortho","recall","consultation","emergency"];
|
const priority = ["endo","implant","crown","pedo","dentures","extraction","perio","filling","ortho","recall","consultation","emergency"];
|
||||||
@@ -473,6 +488,72 @@ router.patch("/:id/confirm", async (req: Request, res: Response): Promise<any> =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add a single attachment to an appointment (incremental insert, does not touch existing files)
|
||||||
|
router.post(
|
||||||
|
"/:appointmentId/files",
|
||||||
|
upload.single("file"),
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const appointmentIdParam = req.params.appointmentId;
|
||||||
|
const appointmentId = parseInt(appointmentIdParam || "", 10);
|
||||||
|
if (isNaN(appointmentId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid appointment ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) return res.status(400).json({ error: "No file uploaded" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appointment = await storage.getAppointment(appointmentId);
|
||||||
|
if (!appointment) {
|
||||||
|
return res.status(404).json({ error: "Appointment not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const patient = await storage.getPatient(appointment.patientId);
|
||||||
|
if (!patient) {
|
||||||
|
return res.status(404).json({ error: "Patient not found" });
|
||||||
|
}
|
||||||
|
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`
|
||||||
|
.trim()
|
||||||
|
.replace(/[/\\?%*:|"<>]/g, "-") || "unknown";
|
||||||
|
|
||||||
|
const folder = await storage.getOrCreatePatientFolder(
|
||||||
|
req.user.id,
|
||||||
|
appointment.patientId,
|
||||||
|
patientName
|
||||||
|
);
|
||||||
|
const attachmentsFolder = await storage.getOrCreateSubfolder(
|
||||||
|
req.user.id,
|
||||||
|
(folder as any).id,
|
||||||
|
"Attachments"
|
||||||
|
);
|
||||||
|
|
||||||
|
const cloudFile = await storage.initializeFileUpload(
|
||||||
|
req.user.id,
|
||||||
|
file.originalname,
|
||||||
|
file.mimetype,
|
||||||
|
BigInt(file.size),
|
||||||
|
1,
|
||||||
|
(attachmentsFolder as any).id
|
||||||
|
);
|
||||||
|
await storage.appendFileChunk((cloudFile as any).id, 0, file.buffer);
|
||||||
|
const finalized = await storage.finalizeFileUpload((cloudFile as any).id);
|
||||||
|
|
||||||
|
const created = await storage.addAppointmentFile(appointmentId, {
|
||||||
|
filename: file.originalname,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
filePath: finalized.diskPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ error: false, data: created });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[appointment-files]", err);
|
||||||
|
return res.status(500).json({ error: "Failed to upload file", message: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Delete an appointment
|
// Delete an appointment
|
||||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ export interface IAppointmentProceduresStorage {
|
|||||||
deleteProcedure(id: number): Promise<void>;
|
deleteProcedure(id: number): Promise<void>;
|
||||||
clearByAppointmentId(appointmentId: number): Promise<void>;
|
clearByAppointmentId(appointmentId: number): Promise<void>;
|
||||||
getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]>;
|
getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]>;
|
||||||
|
addAppointmentFile(
|
||||||
|
appointmentId: number,
|
||||||
|
file: AppointmentFileMeta
|
||||||
|
): Promise<AppointmentFileMeta & { id: number }>;
|
||||||
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
|
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
|
||||||
getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>>;
|
getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>>;
|
||||||
}
|
}
|
||||||
@@ -201,4 +205,24 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
|||||||
filePath: f.filePath,
|
filePath: f.filePath,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addAppointmentFile(
|
||||||
|
appointmentId: number,
|
||||||
|
file: AppointmentFileMeta
|
||||||
|
): Promise<AppointmentFileMeta & { id: number }> {
|
||||||
|
const row = await db.appointmentFile.create({
|
||||||
|
data: {
|
||||||
|
appointmentId,
|
||||||
|
filename: file.filename,
|
||||||
|
mimeType: file.mimeType ?? null,
|
||||||
|
filePath: file.filePath ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
filename: row.filename,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
filePath: row.filePath,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { DateInputField } from "@/components/ui/dateInputField";
|
||||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { AppointmentAttachments } from "@/components/appointments/appointment-attachments";
|
||||||
|
|
||||||
export interface NewAppointmentPrefill {
|
export interface NewAppointmentPrefill {
|
||||||
staffId: number;
|
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">
|
<Button type="submit" disabled={isLoading} className="w-full">
|
||||||
{appointment ? "Update Appointment" : "Create Appointment"}
|
{appointment ? "Update Appointment" : "Create Appointment"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -494,6 +494,10 @@ export function ClaimForm({
|
|||||||
totalPaid: new Decimal(0),
|
totalPaid: new Decimal(0),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (data.appointmentFiles?.length) {
|
||||||
|
setExistingAppointmentFiles(data.appointmentFiles);
|
||||||
|
}
|
||||||
|
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines,
|
serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines,
|
||||||
@@ -900,6 +904,9 @@ export function ClaimForm({
|
|||||||
const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null);
|
const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [priceMismatches, setPriceMismatches] = useState<PriceMismatch[]>([]);
|
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);
|
const pendingClaimAction = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// NO validation here — the upload zone handles validation, toasts, max files, sizes, etc.
|
// NO validation here — the upload zone handles validation, toasts, max files, sizes, etc.
|
||||||
@@ -1617,10 +1624,17 @@ export function ClaimForm({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const attachments = form.uploadedFiles?.length
|
const newAttachments = form.uploadedFiles?.length
|
||||||
? await uploadAttachmentsToLocalFolder(form.uploadedFiles)
|
? 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", {
|
const res = await apiRequest("POST", "/api/appointment-procedures/save-for-appointment", {
|
||||||
appointmentId,
|
appointmentId,
|
||||||
patientId,
|
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)}` : "";
|
const typeMsg = inferredType ? ` · Type → ${getAppointmentTypeLabel(inferredType)}` : "";
|
||||||
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved${typeMsg}.` });
|
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved${typeMsg}.` });
|
||||||
onClose();
|
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