feat: Download Filled Claim Form; BCBS AI claim returns to schedule date

- "Download Claim PDF for Column" -> "Download Filled Claim Form": ZIP of the
  ADA forms the app filled (e.g. BCBS) for appointments on the selected
  schedule date, all columns; Selenium-submitted claims are not included
  (GET /api/claims/filled-forms replaces /batch-pdf)
- After a BCBS claim from "Claim for column with AI", go back to the
  schedule even when the queue is finished
- Schedule remembers the selected date for the tab, so returning from the
  claims page keeps the working date instead of jumping to today

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 10:39:01 -04:00
co-authored by Claude Opus 5.5
parent f55e8c114b
commit c3efdc7f8e
3 changed files with 74 additions and 83 deletions
+42 -48
View File
@@ -1046,74 +1046,68 @@ router.post(
}
);
// GET /api/claims/batch-pdf
// Query params: date=YYYY-MM-DD (required), staffIds=1,2 (required)
// Returns a ZIP archive of all INSURANCE_CLAIM PdfFile records for patients
// scheduled on that date in the given staff columns.
// GET /api/claims/filled-forms?date=YYYY-MM-DD
// ZIP of the claim forms the app fills itself (ADA paper forms, e.g. BCBS) for
// appointments on that schedule date. Claims submitted through Selenium are not included.
router.get(
"/batch-pdf",
"/filled-forms",
async (req: Request, res: Response): Promise<any> => {
const date = String(req.query.date ?? "").trim();
const staffIdsRaw = String(req.query.staffIds ?? "").trim();
if (!date) return res.status(400).json({ error: "Missing date query param" });
if (!staffIdsRaw) return res.status(400).json({ error: "Missing staffIds query param" });
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const staffIdFilter = new Set(
staffIdsRaw.split(",").map(Number).filter((n) => Number.isFinite(n) && n > 0)
);
try {
const allAppointments = await storage.getAppointmentsByDateForUser(date, req.user.id);
const appointments = allAppointments.filter((a) => staffIdFilter.has(Number(a.staffId)));
const appointments = await storage.getAppointmentsByDateForUser(date, req.user.id);
const appointmentIds = appointments.map((a) => a.id).filter((id): id is number => id != null);
const { prisma: db } = await import("@repo/db/client");
const claims = appointmentIds.length
? await db.claim.findMany({
where: {
appointmentId: { in: appointmentIds },
userId: req.user.id,
status: { notIn: ["VOID", "CANCELLED"] },
},
select: { id: true, patientId: true },
})
: [];
type PdfEntry = { filename: string; data: Buffer };
const pdfEntries: PdfEntry[] = [];
for (const apt of appointments) {
const patientId = apt.patientId;
if (!patientId) continue;
const claimIdsByPatient = new Map<number, Set<number>>();
for (const c of claims) {
if (!claimIdsByPatient.has(c.patientId)) claimIdsByPatient.set(c.patientId, new Set());
claimIdsByPatient.get(c.patientId)!.add(c.id);
}
// Claim PDFs from Selenium are stored in PdfGroup (titleKey=INSURANCE_CLAIM) → PdfFile (binary pdfData)
const group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM");
if (!group?.id) continue;
const raw = await storage.getPdfFilesByGroupId(group.id);
const files = (Array.isArray(raw) ? raw : (raw as any).data ?? []) as Array<{ filename: string; pdfData: unknown }>;
for (const f of files) {
if (!f.pdfData) continue;
// Prisma Bytes → always convert to Buffer to satisfy archiver
const buf = Buffer.isBuffer(f.pdfData)
? f.pdfData
: Buffer.from(f.pdfData as any);
if (!buf.length) continue;
// Sanitize filename: strip path components, keep safe chars
const safeName = (f.filename || "claim.pdf").replace(/[/\\]/g, "_").trim() || "claim.pdf";
pdfEntries.push({ filename: safeName, data: buf });
for (const [patientId, claimIds] of claimIdsByPatient) {
// Filled forms are saved as ADA_Claim_<Last>_<First>_<claimId>.pdf in the patient's "Claims" folder;
// keep the newest file per claim
const newestByClaim = new Map<number, { id: number; name: string }>();
for (const cf of await storage.listPatientCategoryCloudFiles(patientId, "Claims")) {
const m = /^ADA_Claim_.*_(\d+)\.pdf$/.exec(cf.name ?? "");
if (!m || cf.id == null) continue;
const claimId = Number(m[1]);
if (!claimIds.has(claimId)) continue;
const prev = newestByClaim.get(claimId);
if (!prev || cf.id > prev.id) newestByClaim.set(claimId, { id: cf.id, name: cf.name! });
}
// New claim PDFs are saved to the patient's Cloud Storage "Claims" subfolder
const cloudFiles = await storage.listPatientCategoryCloudFiles(patientId, "Claims");
for (const cf of cloudFiles) {
if (cf.id == null) continue;
const full = await storage.getFile(cf.id);
for (const { id, name } of newestByClaim.values()) {
const full = await storage.getFile(id);
if (!full?.diskPath) continue;
const abs = path.join(process.cwd(), full.diskPath);
if (!fs.existsSync(abs)) continue;
const buf = fs.readFileSync(abs);
if (!buf.length) continue;
const safeName = (cf.name || "claim.pdf").replace(/[/\\]/g, "_").trim() || "claim.pdf";
pdfEntries.push({ filename: safeName, data: buf });
if (buf.length) pdfEntries.push({ filename: name, data: buf });
}
}
if (pdfEntries.length === 0) {
return res.status(404).json({ error: "No claim PDFs found for the selected columns and date" });
return res.status(404).json({ error: `No filled claim forms for ${date}` });
}
const zipFilename = `claims_${date}.zip`;
const zipFilename = `filled_claim_forms_${date}.zip`;
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${zipFilename}"`);
@@ -1123,7 +1117,7 @@ router.get(
let archiveError: Error | null = null;
archive.on("error", (err) => {
archiveError = err;
console.error("[batch-pdf] archiver error:", err);
console.error("[filled-forms] archiver error:", err);
if (!res.headersSent) {
res.status(500).json({ error: `ZIP creation failed: ${err.message}` });
}
@@ -1147,9 +1141,9 @@ router.get(
await archive.finalize();
}
} catch (err: any) {
console.error("[batch-pdf] error:", err);
console.error("[filled-forms] error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err?.message ?? "Batch PDF download failed" });
return res.status(500).json({ error: err?.message ?? "Filled claim form download failed" });
}
}
}
+25 -35
View File
@@ -151,6 +151,8 @@ function resolveAppointmentBadgeStatus(apt: ScheduledAppointment): PatientStatus
const APPOINTMENT_CONTEXT_MENU_ID = "appointment-context-menu";
// 🔑 exported base key
const SCHEDULE_DATE_KEY = "schedule_selected_date";
export const QK_APPOINTMENTS_BASE = ["appointments", "day"] as const;
// helper (optional) – mirrors the query key structure
export const qkAppointmentsDay = (date: string) =>
@@ -164,7 +166,19 @@ export default function AppointmentsPage() {
const [editingAppointment, setEditingAppointment] = useState<
Appointment | undefined
>(undefined);
const [selectedDate, setSelectedDate] = useState<Date>(startOfToday());
// Remember the working date for this tab, so returning from the claims page keeps it
const [selectedDate, setSelectedDate] = useState<Date>(() => {
try {
const saved = sessionStorage.getItem(SCHEDULE_DATE_KEY);
if (saved) return parseLocalDate(saved);
} catch {}
return startOfToday();
});
useEffect(() => {
try {
sessionStorage.setItem(SCHEDULE_DATE_KEY, formatLocalDate(selectedDate));
} catch {}
}, [selectedDate]);
const [location] = useLocation();
const [confirmDeleteState, setConfirmDeleteState] = useState<{
open: boolean;
@@ -234,7 +248,6 @@ export default function AppointmentsPage() {
const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState<Set<number>>(new Set());
const [isSendingReschedule, setIsSendingReschedule] = useState(false);
const [rescheduleAiFollowUp, setRescheduleAiFollowUp] = useState(true);
const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState<Set<number>>(new Set());
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [editingLabelStaffId, setEditingLabelStaffId] = useState<string | null>(null);
@@ -282,15 +295,6 @@ export default function AppointmentsPage() {
});
};
const toggleDownloadPdfColumn = (staffId: number) => {
setSelectedDownloadPdfColumns((prev) => {
const next = new Set(prev);
if (next.has(staffId)) next.delete(staffId);
else next.add(staffId);
return next;
});
};
const [, setLocation] = useLocation();
// Select Procedures modal state (opened inline, no navigation)
@@ -1499,13 +1503,12 @@ export default function AppointmentsPage() {
};
const handleDownloadClaimPdfs = async () => {
if (!user || selectedDownloadPdfColumns.size === 0) return;
const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(",");
if (!user) return;
setIsDownloadingClaimPdfs(true);
try {
const res = await apiRequest(
"GET",
`/api/claims/batch-pdf?date=${formattedSelectedDate}&staffIds=${staffIdsParam}`
`/api/claims/filled-forms?date=${formattedSelectedDate}`
);
if (!res.ok) {
let errMsg = `Server error ${res.status}`;
@@ -1517,12 +1520,12 @@ export default function AppointmentsPage() {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `claims_${formattedSelectedDate}.zip`;
a.download = `filled_claim_forms_${formattedSelectedDate}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "Download started", description: `Claim PDFs for ${formattedSelectedDate} saved to your Downloads folder.` });
toast({ title: "Download started", description: `Filled claim forms for ${formattedSelectedDate} saved to your Downloads folder.` });
} catch (err: any) {
toast({ title: "Download failed", description: err?.message ?? String(err), variant: "destructive" });
} finally {
@@ -1692,6 +1695,9 @@ export default function AppointmentsPage() {
serviceDate,
autoSubmit: true,
}));
// BCBS is a paper form filled and saved in the background — come straight back here
if (/^bcbs/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1");
else sessionStorage.removeItem("ai_claim_return_to_schedule");
setAiClaimModalOpen(false);
setLocation(`/claims?appointmentId=${appointmentId}`);
};
@@ -2083,11 +2089,11 @@ export default function AppointmentsPage() {
</div>
</div>
{/* Download Claim PDF for Column section */}
{/* Download Filled Claim Form: ADA forms (e.g. BCBS) for the selected date */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleDownloadClaimPdfs()}
disabled={isLoading || isDownloadingClaimPdfs || selectedDownloadPdfColumns.size === 0}
disabled={isLoading || isDownloadingClaimPdfs}
size="sm"
>
{isDownloadingClaimPdfs ? (
@@ -2098,26 +2104,10 @@ export default function AppointmentsPage() {
) : (
<>
<Download className="h-4 w-4 mr-1" />
Download Claim PDF for Column
Download Filled Claim Form
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedDownloadPdfColumns.has(Number(staff.id))}
onChange={() => toggleDownloadPdfColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
</div>
</div>
</div>
+7
View File
@@ -1176,6 +1176,13 @@ export default function ClaimsPage() {
clearUrlParams(["newPatient", "appointmentId"]);
// A BCBS claim started from the schedule returns there even when the queue is done
if (sessionStorage.getItem("ai_claim_return_to_schedule")) {
sessionStorage.removeItem("ai_claim_return_to_schedule");
setWouterLocation("/appointments");
return;
}
// If an AI claim queue is pending resume, go straight back to appointments
try {
const raw = sessionStorage.getItem("ai_claim_queue");