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

@@ -59,9 +59,14 @@ Intents:
e.g. "check Maria Jesus", "verify insurance for John Smith" e.g. "check Maria Jesus", "verify insurance for John Smith"
- eligibility_by_id : user provides a member ID and date of birth (no patient name) - eligibility_by_id : user provides a member ID and date of birth (no patient name)
e.g. "check masshealth for 100xxxx, 10/10/1988" e.g. "check masshealth for 100xxxx, 10/10/1988"
- check_and_claim : user wants to check eligibility AND submit procedures as claims ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
In these cases set appointmentDate to the mentioned date (YYYY-MM-DD)
- check_and_claim : user wants to check eligibility AND submit PROCEDURES/BILLING as claims
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning" e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
e.g. "check Maria Jesus and claim D0120 D1110" e.g. "check Maria Jesus and claim D0120 D1110"
Only use this when procedures or CDT codes are mentioned — NOT for scheduling
- find_patient : look up a patient record only, no eligibility - find_patient : look up a patient record only, no eligibility
e.g. "find patient John", "look up Smith" e.g. "find patient John", "look up Smith"
- schedule_appointment : add a patient to the schedule (today or a specified date/time) - schedule_appointment : add a patient to the schedule (today or a specified date/time)
@@ -84,6 +89,11 @@ Intents:
Rules: Rules:
- For check_and_claim and claim_only, procedureNames should be the RAW user text - For check_and_claim and claim_only, procedureNames should be the RAW user text
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes (e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
- IMPORTANT: If the user says "with the x ray", "with x ray", "attach x ray", "the x ray", "with xray",
"with the attachment", "the attachment", "with attachment", "with the file", "with the image",
"with the scan", "with the photo", "with the document", or any variation meaning an uploaded/attached file,
do NOT include it in procedureNames. It refers to a file attachment, not a billable procedure.
Only include actual clinical procedures in procedureNames.
- For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces: - For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces:
e.g. "composite #29 O", "#8 MO", "composite #11 MOD" — keep the #number and surface letters together as one entry e.g. "composite #29 O", "#8 MO", "composite #11 MOD" — keep the #number and surface letters together as one entry
- For RCT/root canal with a tooth number, preserve the tooth# in the entry: - For RCT/root canal with a tooth number, preserve the tooth# in the entry:

View File

@@ -11,6 +11,13 @@ import { ChatClassification } from "./internal-chat-graph";
import { lookupCdtCodes } from "./cdt-lookup"; import { lookupCdtCodes } from "./cdt-lookup";
import insuranceAliases from "../data/insuranceAliases.json"; import insuranceAliases from "../data/insuranceAliases.json";
// Phrases the user may write to mean "attach the uploaded file" — not CDT procedures
const ATTACHMENT_PHRASES = /^(with\s+)?(the\s+)?(x[\s-]?ray[s]?|xray[s]?|radiograph[s]?|image[s]?|film[s]?|attachment[s]?|attach|file[s]?|photo[s]?|picture[s]?|scan[s]?|doc(ument)?[s]?)$/i;
function stripAttachmentRefs(names: string[]): string[] {
return names.filter((n) => !ATTACHMENT_PHRASES.test(n.trim()));
}
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
export interface ResolvedPatient { export interface ResolvedPatient {
@@ -358,6 +365,7 @@ async function handleEligibilityById(
dob: resolvedDob, dob: resolvedDob,
siteKey, siteKey,
autoCheck: siteKeyToAutoCheck(siteKey), autoCheck: siteKeyToAutoCheck(siteKey),
appointmentDate: c.appointmentDate ?? null,
}, },
}; };
} }
@@ -423,7 +431,7 @@ async function handleCheckAndClaim(
} }
// 3. Map procedure names → CDT codes (custom aliases take priority) // 3. Map procedure names → CDT codes (custom aliases take priority)
const procedureNames = c.procedureNames ?? []; const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
const cdtResults: CdtResult[] = procedureNames.length > 0 const cdtResults: CdtResult[] = procedureNames.length > 0
? lookupCdtCodes(procedureNames, customAliases) ? lookupCdtCodes(procedureNames, customAliases)
: []; : [];
@@ -492,7 +500,7 @@ async function handleClaimOnly(
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
// Map procedure names → CDT codes // Map procedure names → CDT codes
const procedureNames = c.procedureNames ?? []; const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
if (procedureNames.length === 0) { if (procedureNames.length === 0) {
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." }; return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
} }
@@ -741,15 +749,16 @@ async function handleScheduleAppointment(
export async function createAppointmentToday( export async function createAppointmentToday(
patientId: number, patientId: number,
userId: number, userId: number,
storage: StorageLike storage: StorageLike,
targetDate?: string // YYYY-MM-DD; defaults to today
): Promise<{ startTime: string; endTime: string; dateStr: string; dateLabel: string; column: string } | { error: string }> { ): Promise<{ startTime: string; endTime: string; dateStr: string; dateLabel: string; column: string } | { error: string }> {
const today = new Date(); const base = targetDate ? new Date(targetDate + "T00:00:00") : new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; const dateStr = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, "0")}-${String(base.getDate()).padStart(2, "0")}`;
const dateLabel = today.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); const dateLabel = base.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
const localDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const localDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const dayName = dayNames[today.getDay()]!; const dayName = dayNames[base.getDay()]!;
const officeHours = await storage.getOfficeHours(userId); const officeHours = await storage.getOfficeHours(userId);
const dayHours = officeHours?.data?.doctors?.[dayName] ?? { const dayHours = officeHours?.data?.doctors?.[dayName] ?? {
@@ -757,7 +766,7 @@ export async function createAppointmentToday(
}; };
if (!dayHours.enabled) { if (!dayHours.enabled) {
return { error: `The office is closed today (${dayName}). Cannot create appointment.` }; return { error: `The office is closed on ${dayName} (${dateLabel}). Cannot create appointment.` };
} }
const allSlots = buildSlots(dayHours); const allSlots = buildSlots(dayHours);

View File

@@ -78,7 +78,7 @@ export const startBackupCron = () => {
const log = await cronJobLogStorage.createJobLog("local-backup", startedAt); const log = await cronJobLogStorage.createJobLog("local-backup", startedAt);
try { try {
const filename = `dental_backup_${Date.now()}.sql`; const filename = `dental_backup_${Date.now()}.zip`;
await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename }); await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename });
pruneOldBackups(LOCAL_BACKUP_DIR); pruneOldBackups(LOCAL_BACKUP_DIR);
await storage.createBackup(admin.id); await storage.createBackup(admin.id);
@@ -153,7 +153,7 @@ export const startBackupCron = () => {
} }
try { try {
const filename = `dental_backup_usb_${Date.now()}.sql`; const filename = `dental_backup_usb_${Date.now()}.zip`;
await backupDatabaseToPath({ destinationPath: usbBackupPath, filename }); await backupDatabaseToPath({ destinationPath: usbBackupPath, filename });
pruneOldBackups(usbBackupPath); pruneOldBackups(usbBackupPath);
await storage.createBackup(admin.id); await storage.createBackup(admin.id);

View File

@@ -313,9 +313,9 @@ router.post("/create-appointment-today", async (req: Request, res: Response): Pr
try { try {
const userId = req.user?.id; const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" }); if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { patientId } = req.body; const { patientId, date } = req.body;
if (!patientId) return res.status(400).json({ message: "patientId is required" }); if (!patientId) return res.status(400).json({ message: "patientId is required" });
const result = await createAppointmentToday(Number(patientId), userId, storage); const result = await createAppointmentToday(Number(patientId), userId, storage, date ?? undefined);
if ("error" in result) return res.status(409).json({ message: result.error }); if ("error" in result) return res.status(409).json({ message: result.error });
return res.status(200).json(result); return res.status(200).json(result);
} catch (err) { } catch (err) {

View File

@@ -90,45 +90,66 @@ const upload = multer({
}, },
}); });
// Disk-storage uploader for claim attachments saved under uploads/patients/<name>/
const attachmentDiskStorage = multer.diskStorage({
destination: (req, _file, cb) => {
const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim();
const dir = path.join(process.cwd(), "uploads", "patients", patientName);
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
const base = path.basename(file.originalname, ext).replace(/\s+/g, "_");
cb(null, `${Date.now()}_${base}${ext}`);
},
});
const attachmentUpload = multer({
storage: attachmentDiskStorage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
cb(null, allowed.includes(file.mimetype));
},
});
// POST /api/claims/upload-attachments // POST /api/claims/upload-to-cloud
// Saves files to uploads/patients/<patientName>/ and returns their paths. // Saves uploaded files to both:
// - Patient Documents (visible on Documents page)
// - Cloud Storage patient folder (visible on Cloud Storage page)
router.post( router.post(
"/upload-attachments", "/upload-to-cloud",
attachmentUpload.array("files", 10), upload.array("files", 10),
(req: Request, res: Response): any => { async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const files = req.files as Express.Multer.File[]; const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: true, message: "No files uploaded" }); if (!files?.length) return res.status(400).json({ error: "No files uploaded" });
const result = files.map((f) => ({ const patientId = Number(req.body.patientId);
filename: f.originalname, const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim();
mimeType: f.mimetype,
filePath: `/uploads/patients/${path.basename(path.dirname(f.path))}/${path.basename(f.path)}`,
}));
return res.json({ error: false, data: result }); if (!patientId || isNaN(patientId)) {
return res.status(400).json({ error: "Invalid patientId" });
}
try {
const folder = await storage.getOrCreatePatientFolder(req.user.id, patientId, patientName);
const result: { filename: string; mimeType: string; filePath: string }[] = [];
for (const file of files) {
// Save to patient-documents (Documents page)
await storage.createPatientDocument(
patientId,
file.originalname,
file.originalname,
file.mimetype,
file.size,
file.buffer
);
// Save to cloud storage patient folder (Cloud Storage page)
const cloudFile = await storage.initializeFileUpload(
req.user.id,
file.originalname,
file.mimetype,
BigInt(file.size),
1,
(folder as any).id
);
await storage.appendFileChunk((cloudFile as any).id, 0, file.buffer);
await storage.finalizeFileUpload((cloudFile as any).id);
result.push({
filename: file.originalname,
mimeType: file.mimetype,
filePath: `/api/cloud-storage/files/${(cloudFile as any).id}/content`,
});
}
return res.json({ error: false, data: result });
} catch (err: any) {
console.error("[upload-to-cloud]", err);
return res.status(500).json({ error: "Failed to upload files", message: err?.message });
}
} }
); );

View File

@@ -370,7 +370,7 @@ router.post("/backup-path", async (req, res) => {
}); });
} }
const filename = `dental_backup_${Date.now()}.sql`; const filename = `dental_backup_${Date.now()}.zip`;
try { try {
await backupDatabaseToPath({ await backupDatabaseToPath({

View File

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

View File

@@ -135,7 +135,7 @@ export function ChatbotButton() {
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null); const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
const [freeTextInput, setFreeTextInput] = useState(""); const [freeTextInput, setFreeTextInput] = useState("");
const [patientResult, setPatientResult] = useState<PatientResult | null>(null); 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 [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null); const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
const [apptSelectionData, setApptSelectionData] = useState<{ const [apptSelectionData, setApptSelectionData] = useState<{
@@ -299,29 +299,32 @@ export function ChatbotButton() {
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
}; };
const handleEligibilityAndAppointment = async () => { const handleEligibilityAndAppointment = async (targetDate?: string) => {
if (!eligibilityIdData) return; 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) { 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 appointment for ${dateLabel} after...`);
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, date: targetDate ?? null }));
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId }));
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
return; return;
} }
addMsg("bot", "Creating appointment for today...", true); addMsg("bot", `Creating appointment for ${dateLabel}...`, true);
try { try {
const res = await apiRequest("POST", "/api/ai/create-appointment-today", { const res = await apiRequest("POST", "/api/ai/create-appointment-today", {
patientId: eligibilityIdData.patient.id, patientId: eligibilityIdData.patient.id,
date: targetDate ?? undefined,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
replaceLastMsg(data.message ?? "Could not create appointment."); replaceLastMsg(data.message ?? "Could not create appointment.");
return; 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); prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
} catch { } catch {
replaceLastMsg("Could not create appointment. Please try again."); replaceLastMsg("Could not create appointment. Please try again.");
@@ -393,6 +396,7 @@ export function ChatbotButton() {
siteKey: data.actionData.siteKey, siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck, autoCheck: data.actionData.autoCheck,
patient: data.actionData.patient ?? null, patient: data.actionData.patient ?? null,
appointmentDate: data.actionData.appointmentDate ?? null,
}); });
setStep("eligibility-id-ready"); setStep("eligibility-id-ready");
return; return;
@@ -694,11 +698,22 @@ export function ChatbotButton() {
<Button <Button
size="sm" size="sm"
className="w-full h-8 text-xs bg-emerald-600 hover:bg-emerald-700 text-white" 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" /> <Calendar className="h-3 w-3 mr-1" />
Eligibility &amp; Appointment Eligibility &amp; Appointment Today
</Button> </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}> <Button size="sm" variant="ghost" className="w-full h-8 text-xs" onClick={reset}>
Cancel Cancel
</Button> </Button>
@@ -780,6 +795,7 @@ export function ChatbotButton() {
siteKey: data.actionData.siteKey, siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck, autoCheck: data.actionData.autoCheck,
patient: data.actionData.patient ?? null, patient: data.actionData.patient ?? null,
appointmentDate: data.actionData.appointmentDate ?? null,
}); });
setStep("eligibility-id-ready"); setStep("eligibility-id-ready");
} else { } else {

View File

@@ -181,6 +181,16 @@ export default function AppointmentsPage() {
reply: string; reply: string;
} | null>(null); } | null>(null);
const [needsAiClaimResume, setNeedsAiClaimResume] = useState(false); 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 [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
const [isSendingReminders, setIsSendingReminders] = useState(false); const [isSendingReminders, setIsSendingReminders] = useState(false);
const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true); const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true);
@@ -1413,6 +1423,20 @@ export default function AppointmentsPage() {
notes: apt.notes ?? "", notes: apt.notes ?? "",
reply: data.reply ?? "", 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 { } else {
setAiClaimCurrentData({ setAiClaimCurrentData({
matchedCodes: [], matchedCodes: [],
@@ -1484,11 +1508,78 @@ export default function AppointmentsPage() {
}; };
const handleAiClaimSkip = async () => { const handleAiClaimSkip = async () => {
setAiClaimCdtClarification(null);
const nextIndex = aiClaimCurrentIndex + 1; const nextIndex = aiClaimCurrentIndex + 1;
setAiClaimCurrentIndex(nextIndex); setAiClaimCurrentIndex(nextIndex);
await processAiClaimAtIndex(aiClaimQueue, 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 ( return (
<div> <div>
<SeleniumTaskBanner <SeleniumTaskBanner
@@ -2193,6 +2284,68 @@ export default function AppointmentsPage() {
<LoaderCircleIcon className="h-4 w-4 animate-spin text-teal-600" /> <LoaderCircleIcon className="h-4 w-4 animate-spin text-teal-600" />
Interpreting notes with AI... Interpreting notes with AI...
</div> </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 ? ( ) : aiClaimCurrentData ? (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>

View File

@@ -720,7 +720,7 @@ export default function InsuranceStatusPage() {
try { try {
const raw = sessionStorage.getItem("chatbot_appt_after_eligibility"); const raw = sessionStorage.getItem("chatbot_appt_after_eligibility");
if (!raw) return; if (!raw) return;
const { memberId: storedMemberId } = JSON.parse(raw); const { memberId: storedMemberId, date: storedDate } = JSON.parse(raw);
sessionStorage.removeItem("chatbot_appt_after_eligibility"); sessionStorage.removeItem("chatbot_appt_after_eligibility");
if (!storedMemberId) return; if (!storedMemberId) return;
@@ -729,12 +729,15 @@ export default function InsuranceStatusPage() {
const patient = await lookupRes.json(); const patient = await lookupRes.json();
if (!patient?.id) return; 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(); const apptData = await apptRes.json();
if (apptRes.ok) { if (apptRes.ok) {
const scheduledOn = storedDate
? new Date(storedDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "today";
toast({ toast({
title: "Appointment created", 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 { } else {
toast({ toast({