diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index a10f64ac..d641ab85 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -125,6 +125,7 @@ const ALIAS_MAP: Record = { "simple extraction": "extraction erupted", "surgical extraction": "extraction bone", "surgical ext": "extraction bone", + "surg ext": "extraction bone", "baby tooth ext": "extraction primary", "baby tooth extraction": "extraction primary", "primary tooth ext": "extraction primary", @@ -211,6 +212,7 @@ const DIRECT_CODE_MAP: Record = { "primary tooth ext": "D7111", "surgical extraction": "D7210", "surgical ext": "D7210", + "surg ext": "D7210", "soft tissue impaction": "D7220", "partial bony": "D7230", "complete bony": "D7240", diff --git a/apps/Backend/src/routes/appointments.ts b/apps/Backend/src/routes/appointments.ts index df83212e..95edd554 100755 --- a/apps/Backend/src/routes/appointments.ts +++ b/apps/Backend/src/routes/appointments.ts @@ -489,6 +489,27 @@ router.patch("/:id/confirm", async (req: Request, res: Response): Promise = } }); +// List attachments for an appointment +router.get( + "/:appointmentId/files", + async (req: Request, res: Response): Promise => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + + const appointmentId = parseInt(req.params.appointmentId || "", 10); + if (isNaN(appointmentId)) { + return res.status(400).json({ error: "Invalid appointment ID" }); + } + + try { + const files = await storage.getAppointmentFiles(appointmentId); + return res.json({ error: false, data: files }); + } catch (err: any) { + console.error("[appointment-files]", err); + return res.status(500).json({ error: "Failed to load attachments", message: err?.message }); + } + } +); + // Add a single attachment to an appointment (incremental insert, does not touch existing files) router.post( "/:appointmentId/files", @@ -555,6 +576,29 @@ router.post( } ); +// Delete a single attachment from an appointment +router.delete( + "/:appointmentId/files/:fileId", + async (req: Request, res: Response): Promise => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + + const appointmentId = parseInt(req.params.appointmentId || "", 10); + const fileId = parseInt(req.params.fileId || "", 10); + if (isNaN(appointmentId) || isNaN(fileId)) { + return res.status(400).json({ error: "Invalid appointment or file ID" }); + } + + try { + const deleted = await storage.deleteAppointmentFile(appointmentId, fileId); + if (!deleted) return res.status(404).json({ error: "Attachment not found" }); + return res.json({ error: false }); + } catch (err: any) { + console.error("[appointment-files]", err); + return res.status(500).json({ error: "Failed to delete attachment", message: err?.message }); + } + } +); + // Delete an appointment router.delete("/:id", async (req: Request, res: Response): Promise => { try { diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index a60c0352..78e1c234 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -860,7 +860,11 @@ router.post( // straight from disk via filePath (unlike MH/CCA, which get base64 buffers above). const diskClaimFiles = allFileMeta.filter((f) => { if (!f.filePath) return false; - return fs.existsSync(path.join(process.cwd(), f.filePath)); + const exists = fs.existsSync(path.join(process.cwd(), f.filePath)); + if (!exists) { + console.warn(`[batch-column] attachment not found on disk: ${path.join(process.cwd(), f.filePath)}`); + } + return exists; }); // Base claim data shared across all insurance pathways diff --git a/apps/Backend/src/storage/appointment-procedures-storage.ts b/apps/Backend/src/storage/appointment-procedures-storage.ts index 1c02afa9..9f0a7811 100755 --- a/apps/Backend/src/storage/appointment-procedures-storage.ts +++ b/apps/Backend/src/storage/appointment-procedures-storage.ts @@ -6,6 +6,8 @@ import { UpdateAppointmentProcedure, } from "@repo/db/types"; import { prisma as db } from "@repo/db/client"; +import fs from "fs"; +import path from "path"; export interface AppointmentFileMeta { filename: string; @@ -50,6 +52,7 @@ export interface IAppointmentProceduresStorage { appointmentId: number, file: AppointmentFileMeta ): Promise; + deleteAppointmentFile(appointmentId: number, fileId: number): Promise; getAppointmentIdsWithProcedures(ids: number[]): Promise>; getProcedureCodesByAppointmentIds(ids: number[]): Promise>; } @@ -225,4 +228,30 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = { filePath: row.filePath, }; }, + + async deleteAppointmentFile(appointmentId: number, fileId: number): Promise { + const file = await db.appointmentFile.findFirst({ + where: { id: fileId, appointmentId }, + }); + if (!file) return false; + + await db.appointmentFile.delete({ where: { id: fileId } }); + + // Best-effort cleanup of the underlying cloud-storage file/disk copy. + if (file.filePath) { + const cloudFile = await db.cloudFile.findFirst({ + where: { diskPath: file.filePath }, + select: { id: true, diskPath: true }, + }); + if (cloudFile) { + if (cloudFile.diskPath) { + const abs = path.join(process.cwd(), cloudFile.diskPath); + if (fs.existsSync(abs)) fs.unlinkSync(abs); + } + await db.cloudFile.delete({ where: { id: cloudFile.id } }).catch(() => null); + } + } + + return true; + }, }; diff --git a/apps/Frontend/src/components/appointments/appointment-attachments.tsx b/apps/Frontend/src/components/appointments/appointment-attachments.tsx index 9c55b891..47c5d32a 100644 --- a/apps/Frontend/src/components/appointments/appointment-attachments.tsx +++ b/apps/Frontend/src/components/appointments/appointment-attachments.tsx @@ -1,7 +1,7 @@ -import { useRef, useState } from "react"; +import { useEffect, 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 { Paperclip, Image as ImageIcon, Camera, Loader2, Check, RotateCcw, FileText, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -26,7 +26,29 @@ interface AttachmentItem { localId: string; filename: string; status: "uploading" | "done" | "error"; - file: File; + file?: File; + fileId?: number; +} + +async function fetchAppointmentAttachments(appointmentId: number): Promise { + const res = await apiRequest("GET", `/api/appointments/${appointmentId}/files`); + if (!res.ok) return []; + const body = await res.json().catch(() => null); + const files: Array<{ id: number; filename: string }> = body?.data ?? []; + return files.map((f) => ({ + localId: `existing-${f.id}`, + filename: f.filename, + status: "done" as const, + fileId: f.id, + })); +} + +async function deleteAppointmentAttachment(appointmentId: number, fileId: number) { + const res = await apiRequest("DELETE", `/api/appointments/${appointmentId}/files/${fileId}`); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.message || body?.error || "Failed to delete attachment"); + } } function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string { @@ -80,6 +102,18 @@ export function AppointmentAttachments({ appointmentId, patientId }: Appointment const [completedCrop, setCompletedCrop] = useState(); const imgRef = useRef(null); + useEffect(() => { + let cancelled = false; + if (appointmentId) { + void fetchAppointmentAttachments(appointmentId).then((existing) => { + if (!cancelled) setItems((prev) => [...existing, ...prev]); + }); + } + return () => { + cancelled = true; + }; + }, [appointmentId]); + const handleImageLoad = (e: React.SyntheticEvent) => { imgRef.current = e.currentTarget; const { width, height } = e.currentTarget; @@ -107,8 +141,12 @@ export function AppointmentAttachments({ appointmentId, patientId }: Appointment 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))); + const created = await uploadAppointmentAttachment(appointmentId, patientId, file); + setItems((prev) => + prev.map((it) => + it.localId === localId ? { ...it, status: "done", fileId: created?.data?.id } : it + ) + ); } catch (err: any) { setItems((prev) => prev.map((it) => (it.localId === localId ? { ...it, status: "error" } : it))); toast({ @@ -120,10 +158,27 @@ export function AppointmentAttachments({ appointmentId, patientId }: Appointment }; const retryUpload = (item: AttachmentItem) => { + if (!item.file) return; setItems((prev) => prev.filter((it) => it.localId !== item.localId)); void runUpload(item.file); }; + const deleteItem = async (item: AttachmentItem) => { + if (!item.fileId) return; + const prevItems = items; + setItems((prev) => prev.filter((it) => it.localId !== item.localId)); + try { + await deleteAppointmentAttachment(appointmentId, item.fileId); + } catch (err: any) { + setItems(prevItems); + toast({ + title: "Delete failed", + description: err?.message ?? `Failed to delete ${item.filename}.`, + variant: "destructive", + }); + } + }; + const handleDiskFilesSelected = (files: FileList | null) => { if (!files?.length) return; Array.from(files).forEach((file) => void runUpload(file)); @@ -239,6 +294,18 @@ export function AppointmentAttachments({ appointmentId, patientId }: Appointment )} + {item.status === "done" && item.fileId != null && ( + + )} ))} diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index cb0d1f49..2fdc28f0 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -463,10 +463,12 @@ export function ClaimForm({ }, [appointmentId]); // 2b. Prefill procedures from AppointmentProcedure records. - // Skipped when an existing claim was already loaded above. + // Service lines/NPI are skipped when an existing claim was already loaded above + // (claim data takes priority), but appointmentFiles must always be fetched here — + // otherwise existingAppointmentFiles stays empty and handleProceduresSave's merge + // logic silently wipes previously-attached files on the next save. useEffect(() => { if (!appointmentId) return; - if (existingClaimId) return; // existing claim takes priority let cancelled = false; @@ -482,6 +484,12 @@ export function ClaimForm({ if (cancelled) return; + if (data.appointmentFiles?.length) { + setExistingAppointmentFiles(data.appointmentFiles); + } + + if (existingClaimId) return; // existing claim takes priority for the rest + const mappedLines = (data.procedures || []).map((p: any) => ({ procedureCode: p.procedureCode, procedureDate: serviceDate, @@ -494,10 +502,6 @@ export function ClaimForm({ totalPaid: new Decimal(0), })); - if (data.appointmentFiles?.length) { - setExistingAppointmentFiles(data.appointmentFiles); - } - setForm((prev) => ({ ...prev, serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines, @@ -909,6 +913,19 @@ export function ClaimForm({ const [existingAppointmentFiles, setExistingAppointmentFiles] = useState([]); const pendingClaimAction = useRef<(() => void) | null>(null); + // Merges attachments already saved on the appointment (e.g. via the Schedule editor) with + // any newly uploaded in this claim-form session, so Selenium submission always gets every + // file — not just ones uploaded through this form's own file picker in the current session. + const mergeWithExistingAttachments = useCallback( + (newFiles: ClaimFileMeta[]): ClaimFileMeta[] => { + const byFilename = new Map(); + for (const f of existingAppointmentFiles) byFilename.set(f.filename, f); + for (const f of newFiles) byFilename.set(f.filename, f); + return Array.from(byFilename.values()); + }, + [existingAppointmentFiles], + ); + // NO validation here — the upload zone handles validation, toasts, max files, sizes, etc. const handleFilesChange = useCallback((files: File[]) => { setForm((prev) => ({ ...prev, uploadedFiles: files })); @@ -972,9 +989,9 @@ export function ClaimForm({ } = f; // Save uploaded files to disk (uploads/patients//) and record their paths - const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length - ? await uploadAttachmentsToLocalFolder(uploadedFiles) - : []; + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [], + ); const selectedNpiProviderId = npiProvider?.npiNumber ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null @@ -1103,10 +1120,12 @@ export function ClaimForm({ const appointmentIdToUse = appointmentId ?? null; const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; - const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((file) => ({ - filename: file.name, - mimeType: file.type, - })); + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + (uploadedFiles || []).map((file) => ({ + filename: file.name, + mimeType: file.type, + })), + ); const selectedNpiProviderId = npiProvider?.npiNumber ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null @@ -1172,9 +1191,9 @@ export function ClaimForm({ const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; // Upload files to server so we get local filePaths for Selenium - const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length - ? await uploadAttachmentsToLocalFolder(uploadedFiles) - : []; + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [], + ); const selectedNpiProviderId = npiProvider?.npiNumber ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null @@ -1238,9 +1257,9 @@ export function ClaimForm({ const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; - const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length - ? await uploadAttachmentsToLocalFolder(uploadedFiles) - : []; + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [], + ); const selectedNpiProviderId = npiProvider?.npiNumber ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null @@ -1304,9 +1323,9 @@ export function ClaimForm({ const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; - const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length - ? await uploadAttachmentsToLocalFolder(uploadedFiles) - : []; + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [], + ); const selectedNpiProviderId = npiProvider?.npiNumber ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null @@ -1573,9 +1592,9 @@ export function ClaimForm({ const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToSave } = form; - const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length - ? await uploadAttachmentsToLocalFolder(uploadedFiles) - : []; + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [], + ); // Find the npiProviderId matching the currently selected NPI provider const selectedNpiProviderId = npiProvider?.npiNumber @@ -1717,9 +1736,9 @@ export function ClaimForm({ const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form; - const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length - ? await uploadAttachmentsToLocalFolder(uploadedFiles) - : []; + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [], + ); const selectedNpiProviderId = npiProvider?.npiNumber ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null