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 // GET /api/claims/filled-forms?date=YYYY-MM-DD
// Query params: date=YYYY-MM-DD (required), staffIds=1,2 (required) // ZIP of the claim forms the app fills itself (ADA paper forms, e.g. BCBS) for
// Returns a ZIP archive of all INSURANCE_CLAIM PdfFile records for patients // appointments on that schedule date. Claims submitted through Selenium are not included.
// scheduled on that date in the given staff columns.
router.get( router.get(
"/batch-pdf", "/filled-forms",
async (req: Request, res: Response): Promise<any> => { async (req: Request, res: Response): Promise<any> => {
const date = String(req.query.date ?? "").trim(); 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 (!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" }); 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 { try {
const allAppointments = await storage.getAppointmentsByDateForUser(date, req.user.id); const appointments = await storage.getAppointmentsByDateForUser(date, req.user.id);
const appointments = allAppointments.filter((a) => staffIdFilter.has(Number(a.staffId))); 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 }; type PdfEntry = { filename: string; data: Buffer };
const pdfEntries: PdfEntry[] = []; const pdfEntries: PdfEntry[] = [];
for (const apt of appointments) { const claimIdsByPatient = new Map<number, Set<number>>();
const patientId = apt.patientId; for (const c of claims) {
if (!patientId) continue; 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 });
} }
// New claim PDFs are saved to the patient's Cloud Storage "Claims" subfolder for (const [patientId, claimIds] of claimIdsByPatient) {
const cloudFiles = await storage.listPatientCategoryCloudFiles(patientId, "Claims"); // Filled forms are saved as ADA_Claim_<Last>_<First>_<claimId>.pdf in the patient's "Claims" folder;
for (const cf of cloudFiles) { // keep the newest file per claim
if (cf.id == null) continue; const newestByClaim = new Map<number, { id: number; name: string }>();
const full = await storage.getFile(cf.id); 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! });
}
for (const { id, name } of newestByClaim.values()) {
const full = await storage.getFile(id);
if (!full?.diskPath) continue; if (!full?.diskPath) continue;
const abs = path.join(process.cwd(), full.diskPath); const abs = path.join(process.cwd(), full.diskPath);
if (!fs.existsSync(abs)) continue; if (!fs.existsSync(abs)) continue;
const buf = fs.readFileSync(abs); const buf = fs.readFileSync(abs);
if (!buf.length) continue; if (buf.length) pdfEntries.push({ filename: name, data: buf });
const safeName = (cf.name || "claim.pdf").replace(/[/\\]/g, "_").trim() || "claim.pdf";
pdfEntries.push({ filename: safeName, data: buf });
} }
} }
if (pdfEntries.length === 0) { 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-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${zipFilename}"`); res.setHeader("Content-Disposition", `attachment; filename="${zipFilename}"`);
@@ -1123,7 +1117,7 @@ router.get(
let archiveError: Error | null = null; let archiveError: Error | null = null;
archive.on("error", (err) => { archive.on("error", (err) => {
archiveError = err; archiveError = err;
console.error("[batch-pdf] archiver error:", err); console.error("[filled-forms] archiver error:", err);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ error: `ZIP creation failed: ${err.message}` }); res.status(500).json({ error: `ZIP creation failed: ${err.message}` });
} }
@@ -1147,9 +1141,9 @@ router.get(
await archive.finalize(); await archive.finalize();
} }
} catch (err: any) { } catch (err: any) {
console.error("[batch-pdf] error:", err); console.error("[filled-forms] error:", err);
if (!res.headersSent) { 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"; const APPOINTMENT_CONTEXT_MENU_ID = "appointment-context-menu";
// 🔑 exported base key // 🔑 exported base key
const SCHEDULE_DATE_KEY = "schedule_selected_date";
export const QK_APPOINTMENTS_BASE = ["appointments", "day"] as const; export const QK_APPOINTMENTS_BASE = ["appointments", "day"] as const;
// helper (optional) – mirrors the query key structure // helper (optional) – mirrors the query key structure
export const qkAppointmentsDay = (date: string) => export const qkAppointmentsDay = (date: string) =>
@@ -164,7 +166,19 @@ export default function AppointmentsPage() {
const [editingAppointment, setEditingAppointment] = useState< const [editingAppointment, setEditingAppointment] = useState<
Appointment | undefined Appointment | undefined
>(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 [location] = useLocation();
const [confirmDeleteState, setConfirmDeleteState] = useState<{ const [confirmDeleteState, setConfirmDeleteState] = useState<{
open: boolean; open: boolean;
@@ -234,7 +248,6 @@ export default function AppointmentsPage() {
const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState<Set<number>>(new Set()); const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState<Set<number>>(new Set());
const [isSendingReschedule, setIsSendingReschedule] = useState(false); const [isSendingReschedule, setIsSendingReschedule] = useState(false);
const [rescheduleAiFollowUp, setRescheduleAiFollowUp] = useState(true); const [rescheduleAiFollowUp, setRescheduleAiFollowUp] = useState(true);
const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState<Set<number>>(new Set());
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false); const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [editingLabelStaffId, setEditingLabelStaffId] = useState<string | null>(null); 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(); const [, setLocation] = useLocation();
// Select Procedures modal state (opened inline, no navigation) // Select Procedures modal state (opened inline, no navigation)
@@ -1499,13 +1503,12 @@ export default function AppointmentsPage() {
}; };
const handleDownloadClaimPdfs = async () => { const handleDownloadClaimPdfs = async () => {
if (!user || selectedDownloadPdfColumns.size === 0) return; if (!user) return;
const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(",");
setIsDownloadingClaimPdfs(true); setIsDownloadingClaimPdfs(true);
try { try {
const res = await apiRequest( const res = await apiRequest(
"GET", "GET",
`/api/claims/batch-pdf?date=${formattedSelectedDate}&staffIds=${staffIdsParam}` `/api/claims/filled-forms?date=${formattedSelectedDate}`
); );
if (!res.ok) { if (!res.ok) {
let errMsg = `Server error ${res.status}`; let errMsg = `Server error ${res.status}`;
@@ -1517,12 +1520,12 @@ export default function AppointmentsPage() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `claims_${formattedSelectedDate}.zip`; a.download = `filled_claim_forms_${formattedSelectedDate}.zip`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); 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) { } catch (err: any) {
toast({ title: "Download failed", description: err?.message ?? String(err), variant: "destructive" }); toast({ title: "Download failed", description: err?.message ?? String(err), variant: "destructive" });
} finally { } finally {
@@ -1692,6 +1695,9 @@ export default function AppointmentsPage() {
serviceDate, serviceDate,
autoSubmit: true, 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); setAiClaimModalOpen(false);
setLocation(`/claims?appointmentId=${appointmentId}`); setLocation(`/claims?appointmentId=${appointmentId}`);
}; };
@@ -2083,11 +2089,11 @@ export default function AppointmentsPage() {
</div> </div>
</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"> <div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button <Button
onClick={() => handleDownloadClaimPdfs()} onClick={() => handleDownloadClaimPdfs()}
disabled={isLoading || isDownloadingClaimPdfs || selectedDownloadPdfColumns.size === 0} disabled={isLoading || isDownloadingClaimPdfs}
size="sm" size="sm"
> >
{isDownloadingClaimPdfs ? ( {isDownloadingClaimPdfs ? (
@@ -2098,26 +2104,10 @@ export default function AppointmentsPage() {
) : ( ) : (
<> <>
<Download className="h-4 w-4 mr-1" /> <Download className="h-4 w-4 mr-1" />
Download Claim PDF for Column Download Filled Claim Form
</> </>
)} )}
</Button> </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> </div>
</div> </div>
+7
View File
@@ -1176,6 +1176,13 @@ export default function ClaimsPage() {
clearUrlParams(["newPatient", "appointmentId"]); 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 // If an AI claim queue is pending resume, go straight back to appointments
try { try {
const raw = sessionStorage.getItem("ai_claim_queue"); const raw = sessionStorage.getItem("ai_claim_queue");