diff --git a/apps/Backend/src/routes/appointments.ts b/apps/Backend/src/routes/appointments.ts index 173b90b4..37e556dd 100755 --- a/apps/Backend/src/routes/appointments.ts +++ b/apps/Backend/src/routes/appointments.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import type { Request, Response } from "express"; import { storage } from "../storage"; import { z } from "zod"; +import multer from "multer"; import { insertAppointmentSchema, updateAppointmentSchema, @@ -9,6 +10,20 @@ import { 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 function inferApptType(codes: string[]): string | null { 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 = } }); +// 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 => { + 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 router.delete("/:id", async (req: Request, res: Response): Promise => { try { diff --git a/apps/Backend/src/storage/appointment-procedures-storage.ts b/apps/Backend/src/storage/appointment-procedures-storage.ts index 31b22947..1c02afa9 100755 --- a/apps/Backend/src/storage/appointment-procedures-storage.ts +++ b/apps/Backend/src/storage/appointment-procedures-storage.ts @@ -46,6 +46,10 @@ export interface IAppointmentProceduresStorage { deleteProcedure(id: number): Promise; clearByAppointmentId(appointmentId: number): Promise; getAppointmentFiles(appointmentId: number): Promise; + addAppointmentFile( + appointmentId: number, + file: AppointmentFileMeta + ): Promise; getAppointmentIdsWithProcedures(ids: number[]): Promise>; getProcedureCodesByAppointmentIds(ids: number[]): Promise>; } @@ -201,4 +205,24 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = { filePath: f.filePath, })); }, + + async addAppointmentFile( + appointmentId: number, + file: AppointmentFileMeta + ): Promise { + 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, + }; + }, }; diff --git a/apps/Frontend/src/components/appointments/appointment-attachments.tsx b/apps/Frontend/src/components/appointments/appointment-attachments.tsx new file mode 100644 index 00000000..9c55b891 --- /dev/null +++ b/apps/Frontend/src/components/appointments/appointment-attachments.tsx @@ -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([]); + const [pickerOpen, setPickerOpen] = useState(false); + const fileInputRef = useRef(null); + const { capture, isCapturing, countdown } = useScreenCapture(); + + // Crop dialog state, shown after a screenshot is captured + const [screenshotDataUrl, setScreenshotDataUrl] = useState(null); + const [screenshotFileName, setScreenshotFileName] = useState(""); + const [crop, setCrop] = useState(); + const [completedCrop, setCompletedCrop] = useState(); + const imgRef = useRef(null); + + const handleImageLoad = (e: React.SyntheticEvent) => { + 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 ( +
+
+ Attachments +
+ + + +
+ handleDiskFilesSelected(e.target.files)} + /> +
+ + {items.length > 0 && ( +
    + {items.map((item) => ( +
  • + + {item.filename} + {item.status === "uploading" && } + {item.status === "done" && } + {item.status === "error" && ( + + )} +
  • + ))} +
+ )} + + setPickerOpen(false)} + patientId={patientId} + onFilesSelected={handleServerFilesSelected} + /> + + !open && closeCropDialog()}> + + + Crop screenshot + + {screenshotDataUrl && ( +
+ setCrop(percentCrop)} + onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)} + > + Captured screenshot + +

+ Drag the corners to crop, or leave as-is to attach the full screenshot. +

+
+ +
+ setScreenshotFileName(e.target.value)} + placeholder="screenshot" + /> + .png +
+
+
+ )} + + + + +
+
+
+ ); +} diff --git a/apps/Frontend/src/components/appointments/appointment-form.tsx b/apps/Frontend/src/components/appointments/appointment-form.tsx index 1cdd592f..8670c34a 100755 --- a/apps/Frontend/src/components/appointments/appointment-form.tsx +++ b/apps/Frontend/src/components/appointments/appointment-form.tsx @@ -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 && ( + + )} + diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 90b8ab9b..cb0d1f49 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -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(null); const [isUploading, setIsUploading] = useState(false); const [priceMismatches, setPriceMismatches] = useState([]); + // 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([]); 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(); + 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(); diff --git a/apps/Frontend/src/hooks/use-screen-capture.ts b/apps/Frontend/src/hooks/use-screen-capture.ts new file mode 100644 index 00000000..2fa4a2bf --- /dev/null +++ b/apps/Frontend/src/hooks/use-screen-capture.ts @@ -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(null); + const streamRef = useRef(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 { + 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((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 }; +}