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:
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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 & Appointment
|
||||
Eligibility & 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 & 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 {
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user