feat: save claim attachments to cloud storage and documents page

- Claim file uploads (chatbot or manual) now save to both the Cloud
  Storage patient folder and the Documents page via new
  POST /api/claims/upload-to-cloud endpoint
- MH submit flow now calls uploadAttachmentsToLocalFolder (same as
  DDMA/United/Tufts) so chatbot-attached X-rays are persisted
- Removed old /upload-attachments disk route and attachmentDiskStorage
  multer config; deleted uploads/patients/ folder
- uploadAttachmentsToLocalFolder now points to /upload-to-cloud and
  sends patientId so the backend can create the patient folder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-11 00:30:32 -04:00
parent 46daeb1c1f
commit d4b9c1b889
10 changed files with 280 additions and 68 deletions

View File

@@ -899,11 +899,10 @@ export function ClaimForm({
...formToCreateClaim
} = f;
// build claimFiles metadata from uploadedFiles (only filename + mimeType)
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
filename: f.name,
mimeType: f.type,
}));
// Save uploaded files to disk (uploads/patients/<name>/) and record their paths
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(uploadedFiles)
: [];
const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
@@ -1423,10 +1422,11 @@ export function ClaimForm({
: patient?.firstName ?? `patient-${patientId}`;
const formData = new FormData();
formData.append("patientId", String(patientId));
formData.append("patientName", patientName);
files.forEach((f) => formData.append("files", f));
const res = await apiRequest("POST", "/api/claims/upload-attachments", formData);
const res = await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
const data = await res.json();
return (data.data ?? []) as ClaimFileMeta[];
};

View File

@@ -135,7 +135,7 @@ export function ChatbotButton() {
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
const [freeTextInput, setFreeTextInput] = useState("");
const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null);
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; appointmentDate?: string | null } | null>(null);
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
const [apptSelectionData, setApptSelectionData] = useState<{
@@ -299,29 +299,32 @@ export function ChatbotButton() {
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
};
const handleEligibilityAndAppointment = async () => {
const handleEligibilityAndAppointment = async (targetDate?: string) => {
if (!eligibilityIdData) return;
addMsg("user", "Check eligibility & add to today's schedule");
const dateLabel = targetDate
? new Date(targetDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "today";
addMsg("user", `Check eligibility & add to schedule (${dateLabel})`);
if (!eligibilityIdData.patient) {
// Patient not yet in DB — eligibility check will create them; flag for post-check appointment
addMsg("bot", "Running eligibility check — will add patient and create today's appointment after...");
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId }));
addMsg("bot", `Running eligibility check will add patient and create appointment for ${dateLabel} after...`);
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId, date: targetDate ?? null }));
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
return;
}
addMsg("bot", "Creating appointment for today...", true);
addMsg("bot", `Creating appointment for ${dateLabel}...`, true);
try {
const res = await apiRequest("POST", "/api/ai/create-appointment-today", {
patientId: eligibilityIdData.patient.id,
date: targetDate ?? undefined,
});
const data = await res.json();
if (!res.ok) {
replaceLastMsg(data.message ?? "Could not create appointment.");
return;
}
replaceLastMsg(`Appointment added at ${data.startTime} (${data.column ?? "Column A"}) — opening eligibility check page...`);
replaceLastMsg(`Appointment added at ${data.startTime} (${data.column ?? "Column A"}) for ${data.dateLabel} — opening eligibility check page...`);
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
} catch {
replaceLastMsg("Could not create appointment. Please try again.");
@@ -393,6 +396,7 @@ export function ChatbotButton() {
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
patient: data.actionData.patient ?? null,
appointmentDate: data.actionData.appointmentDate ?? null,
});
setStep("eligibility-id-ready");
return;
@@ -694,11 +698,22 @@ export function ChatbotButton() {
<Button
size="sm"
className="w-full h-8 text-xs bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleEligibilityAndAppointment}
onClick={() => handleEligibilityAndAppointment()}
>
<Calendar className="h-3 w-3 mr-1" />
Eligibility &amp; Appointment
Eligibility &amp; Appointment Today
</Button>
{eligibilityIdData.appointmentDate && (
<Button
size="sm"
className="w-full h-8 text-xs bg-teal-600 hover:bg-teal-700 text-white"
onClick={() => handleEligibilityAndAppointment(eligibilityIdData!.appointmentDate!)}
>
<Calendar className="h-3 w-3 mr-1" />
Eligibility &amp; Appointment on{" "}
{new Date(eligibilityIdData.appointmentDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</Button>
)}
<Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={reset}>
Cancel
</Button>
@@ -780,6 +795,7 @@ export function ChatbotButton() {
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
patient: data.actionData.patient ?? null,
appointmentDate: data.actionData.appointmentDate ?? null,
});
setStep("eligibility-id-ready");
} else {

View File

@@ -181,6 +181,16 @@ export default function AppointmentsPage() {
reply: string;
} | null>(null);
const [needsAiClaimResume, setNeedsAiClaimResume] = useState(false);
const [aiClaimCdtClarification, setAiClaimCdtClarification] = useState<{
unknownPhrases: string[];
codeInputs: Record<string, string>;
originalMessage: string;
aptDate: string;
appointmentId: number;
patientName: string;
notes: string;
matchedSoFar: Array<{ code: string; description: string }>;
} | null>(null);
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true);
@@ -1413,6 +1423,20 @@ export default function AppointmentsPage() {
notes: apt.notes ?? "",
reply: data.reply ?? "",
});
} else if (data.action === "need_cdt_clarification" && data.actionData) {
const phrases: string[] = data.actionData.unknownPhrases ?? [];
const inputs: Record<string, string> = {};
for (const p of phrases) inputs[p] = "";
setAiClaimCdtClarification({
unknownPhrases: phrases,
codeInputs: inputs,
originalMessage: `claim ${apt.notes} for ${apt.patientName}`,
aptDate,
appointmentId: Number(apt.id),
patientName: apt.patientName,
notes: apt.notes ?? "",
matchedSoFar: data.actionData.matchedSoFar ?? [],
});
} else {
setAiClaimCurrentData({
matchedCodes: [],
@@ -1484,11 +1508,78 @@ export default function AppointmentsPage() {
};
const handleAiClaimSkip = async () => {
setAiClaimCdtClarification(null);
const nextIndex = aiClaimCurrentIndex + 1;
setAiClaimCurrentIndex(nextIndex);
await processAiClaimAtIndex(aiClaimQueue, nextIndex);
};
const handleAiClaimCdtSubmit = async () => {
if (!aiClaimCdtClarification) return;
const { codeInputs, originalMessage, aptDate, appointmentId, patientName, notes, matchedSoFar } = aiClaimCdtClarification;
setAiClaimCdtClarification(null);
setIsAiClaimProcessing(true);
setAiClaimCurrentData(null);
try {
for (const [phrase, code] of Object.entries(codeInputs)) {
await apiRequest("POST", "/api/ai/cdt-aliases/add", { phrase, cdtCode: code.trim() });
}
const res = await apiRequest("POST", "/api/ai/internal-chat", {
message: originalMessage,
clientDate: aptDate,
});
const data = await res.json();
if ((data.action === "claim_only_ready" || data.action === "check_and_claim_ready") && data.actionData) {
setAiClaimCurrentData({
matchedCodes: data.actionData.matchedCodes ?? [],
siteKey: data.actionData.siteKey ?? "",
serviceDate: data.actionData.serviceDate ?? aptDate,
appointmentId,
patientName,
notes,
reply: data.reply ?? "",
});
} else if (data.action === "need_cdt_clarification" && data.actionData) {
// Still has unknowns — loop again
const phrases: string[] = data.actionData.unknownPhrases ?? [];
const inputs: Record<string, string> = {};
for (const p of phrases) inputs[p] = "";
setAiClaimCdtClarification({
unknownPhrases: phrases,
codeInputs: inputs,
originalMessage,
aptDate,
appointmentId,
patientName,
notes,
matchedSoFar: data.actionData.matchedSoFar ?? matchedSoFar,
});
} else {
setAiClaimCurrentData({
matchedCodes: matchedSoFar.map((c) => ({ ...c, toothNumber: undefined, toothSurface: undefined })),
siteKey: "",
serviceDate: aptDate,
appointmentId,
patientName,
notes,
reply: data.reply ?? "Could not fully interpret notes.",
});
}
} catch {
setAiClaimCurrentData({
matchedCodes: matchedSoFar.map((c) => ({ ...c, toothNumber: undefined, toothSurface: undefined })),
siteKey: "",
serviceDate: aptDate,
appointmentId,
patientName,
notes,
reply: "Error retrying after alias save.",
});
} finally {
setIsAiClaimProcessing(false);
}
};
return (
<div>
<SeleniumTaskBanner
@@ -2193,6 +2284,68 @@ export default function AppointmentsPage() {
<LoaderCircleIcon className="h-4 w-4 animate-spin text-teal-600" />
Interpreting notes with AI...
</div>
) : aiClaimCdtClarification ? (
<div className="space-y-3">
<div>
<p className="text-sm font-semibold">{aiClaimCdtClarification.patientName}</p>
<p className="text-xs text-gray-500 mt-0.5">
Notes: <span className="italic">{aiClaimCdtClarification.notes}</span>
</p>
</div>
<p className="text-xs font-medium text-amber-700">
Unknown term{aiClaimCdtClarification.unknownPhrases.length > 1 ? "s" : ""} enter CDT code{aiClaimCdtClarification.unknownPhrases.length > 1 ? "s" : ""}:
</p>
{aiClaimCdtClarification.matchedSoFar.length > 0 && (
<div className="bg-teal-50 border border-teal-200 rounded p-2 space-y-0.5">
<p className="text-[10px] text-teal-700 font-medium uppercase tracking-wide">Already matched</p>
{aiClaimCdtClarification.matchedSoFar.map((c) => (
<p key={c.code} className="text-xs">
<span className="font-semibold text-teal-800">{c.code}</span>
<span className="text-gray-600"> {c.description}</span>
</p>
))}
</div>
)}
<div className="space-y-2">
{aiClaimCdtClarification.unknownPhrases.map((phrase) => (
<div key={phrase} className="flex items-center gap-2">
<span className="text-xs text-gray-700 font-medium shrink-0">"{phrase}" </span>
<input
type="text"
placeholder="D0272"
value={aiClaimCdtClarification.codeInputs[phrase] ?? ""}
onChange={(e) =>
setAiClaimCdtClarification((prev) =>
prev ? { ...prev, codeInputs: { ...prev.codeInputs, [phrase]: e.target.value.toUpperCase() } } : prev
)
}
className="flex-1 rounded border border-amber-300 bg-white px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<button
className="flex-1 flex items-center justify-center gap-1 text-xs h-8 px-3 rounded bg-amber-600 hover:bg-amber-700 text-white font-medium transition-colors disabled:opacity-50"
disabled={Object.values(aiClaimCdtClarification.codeInputs).some((v) => !v.trim())}
onClick={handleAiClaimCdtSubmit}
>
Save &amp; Retry
</button>
<button
className="flex items-center justify-center gap-1 text-xs h-8 px-3 rounded border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium transition-colors"
onClick={() => { setAiClaimCdtClarification(null); handleAiClaimSkip(); }}
>
Skip
</button>
<button
className="flex items-center justify-center gap-1 text-xs h-8 px-3 rounded hover:bg-gray-100 text-gray-500 transition-colors"
onClick={() => { setAiClaimCdtClarification(null); setAiClaimModalOpen(false); sessionStorage.removeItem("ai_claim_queue"); }}
>
Cancel All
</button>
</div>
</div>
) : aiClaimCurrentData ? (
<div className="space-y-3">
<div>

View File

@@ -720,7 +720,7 @@ export default function InsuranceStatusPage() {
try {
const raw = sessionStorage.getItem("chatbot_appt_after_eligibility");
if (!raw) return;
const { memberId: storedMemberId } = JSON.parse(raw);
const { memberId: storedMemberId, date: storedDate } = JSON.parse(raw);
sessionStorage.removeItem("chatbot_appt_after_eligibility");
if (!storedMemberId) return;
@@ -729,12 +729,15 @@ export default function InsuranceStatusPage() {
const patient = await lookupRes.json();
if (!patient?.id) return;
const apptRes = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patient.id });
const apptRes = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patient.id, date: storedDate ?? undefined });
const apptData = await apptRes.json();
if (apptRes.ok) {
const scheduledOn = storedDate
? new Date(storedDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "today";
toast({
title: "Appointment created",
description: `${patient.firstName ?? ""} ${patient.lastName ?? ""} added to today's schedule at ${apptData.startTime} (${apptData.column ?? "Column A"}).`.trim(),
description: `${patient.firstName ?? ""} ${patient.lastName ?? ""} added to schedule (${scheduledOn}) at ${apptData.startTime} (${apptData.column ?? "Column A"}).`.trim(),
});
} else {
toast({