fix: persist and correctly forward appointment attachments to insurance claims

- Add "surg ext" alias mapping to D7210 in the AI CDT lookup.
- Add GET/DELETE endpoints for appointment attachments, and load existing
  attachments in the appointment edit modal (they were uploaded correctly
  but never re-fetched, so they appeared to vanish on reopen).
- Fix claim-form.tsx so previously-saved appointment attachments are always
  loaded (even when a claim already exists) instead of only when creating a
  brand-new claim, preventing them from being silently wiped on save.
- Merge existing appointment attachments into every insurance submit handler
  (MassHealth, CCA, DDMA, UnitedDH, TuftsSCO) so Selenium submission always
  includes files saved earlier via the Schedule editor, not just files
  uploaded in the current claim-form session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 21:03:32 -04:00
parent b1932fa3d6
commit a42ef5f570
6 changed files with 199 additions and 34 deletions

View File

@@ -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<AttachmentItem[]> {
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<PixelCrop>();
const imgRef = useRef<HTMLImageElement | null>(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<HTMLImageElement>) => {
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
<RotateCcw className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
{item.status === "done" && item.fileId != null && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
title="Delete attachment"
onClick={() => deleteItem(item)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</li>
))}
</ul>

View File

@@ -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<ClaimFileMeta[]>([]);
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<string, ClaimFileMeta>();
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/<name>/) 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