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:
@@ -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)
|
for (const [patientId, claimIds] of claimIdsByPatient) {
|
||||||
const group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM");
|
// Filled forms are saved as ADA_Claim_<Last>_<First>_<claimId>.pdf in the patient's "Claims" folder;
|
||||||
if (!group?.id) continue;
|
// keep the newest file per claim
|
||||||
|
const newestByClaim = new Map<number, { id: number; name: string }>();
|
||||||
const raw = await storage.getPdfFilesByGroupId(group.id);
|
for (const cf of await storage.listPatientCategoryCloudFiles(patientId, "Claims")) {
|
||||||
const files = (Array.isArray(raw) ? raw : (raw as any).data ?? []) as Array<{ filename: string; pdfData: unknown }>;
|
const m = /^ADA_Claim_.*_(\d+)\.pdf$/.exec(cf.name ?? "");
|
||||||
|
if (!m || cf.id == null) continue;
|
||||||
for (const f of files) {
|
const claimId = Number(m[1]);
|
||||||
if (!f.pdfData) continue;
|
if (!claimIds.has(claimId)) continue;
|
||||||
// Prisma Bytes → always convert to Buffer to satisfy archiver
|
const prev = newestByClaim.get(claimId);
|
||||||
const buf = Buffer.isBuffer(f.pdfData)
|
if (!prev || cf.id > prev.id) newestByClaim.set(claimId, { id: cf.id, name: cf.name! });
|
||||||
? 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 { id, name } of newestByClaim.values()) {
|
||||||
// New claim PDFs are saved to the patient's Cloud Storage "Claims" subfolder
|
const full = await storage.getFile(id);
|
||||||
const cloudFiles = await storage.listPatientCategoryCloudFiles(patientId, "Claims");
|
|
||||||
for (const cf of cloudFiles) {
|
|
||||||
if (cf.id == null) continue;
|
|
||||||
const full = await storage.getFile(cf.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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user