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

@@ -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>