diff --git a/apps/Backend/.env b/apps/Backend/.env index 0dabc4c7..3f4370ba 100755 --- a/apps/Backend/.env +++ b/apps/Backend/.env @@ -1,7 +1,7 @@ NODE_ENV="development" HOST=0.0.0.0 PORT=5000 -FRONTEND_URLS=http://localhost:3000,http://192.168.1.236 +FRONTEND_URLS=http://localhost:3000,http://192.168.1.236,https://communitydentistsoflowell.mydentalofficemanagement.com SELENIUM_AGENT_BASE_URL=http://localhost:5002 JWT_SECRET = 'dentalsecret' DB_HOST=localhost diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index 7c00d1e9..b31ad952 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -7,6 +7,7 @@ import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient"; import path from "path"; import fs from "fs"; import axios from "axios"; +import archiver from "archiver"; import { seleniumQueue } from "../queue/queues"; import { Prisma } from "@repo/db/generated/prisma"; import { Decimal } from "decimal.js"; @@ -688,6 +689,101 @@ 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. +router.get( + "/batch-pdf", + async (req: Request, res: Response): Promise => { + 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))); + + type PdfEntry = { filename: string; data: Buffer }; + const pdfEntries: PdfEntry[] = []; + + for (const apt of appointments) { + const patientId = apt.patientId; + if (!patientId) continue; + + // 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 }); + } + } + + if (pdfEntries.length === 0) { + return res.status(404).json({ error: "No claim PDFs found for the selected columns and date" }); + } + + const zipFilename = `claims_${date}.zip`; + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Disposition", `attachment; filename="${zipFilename}"`); + + const archive = archiver("zip", { zlib: { level: 6 } }); + + // Capture archiver errors before any data is written + let archiveError: Error | null = null; + archive.on("error", (err) => { + archiveError = err; + console.error("[batch-pdf] archiver error:", err); + if (!res.headersSent) { + res.status(500).json({ error: `ZIP creation failed: ${err.message}` }); + } + }); + + archive.pipe(res); + + const seenNames = new Map(); + for (const entry of pdfEntries) { + if (archiveError) break; + const count = seenNames.get(entry.filename) ?? 0; + seenNames.set(entry.filename, count + 1); + const archiveName = + count === 0 + ? entry.filename + : `${path.basename(entry.filename, ".pdf")}_${count}.pdf`; + archive.append(entry.data, { name: archiveName }); + } + + if (!archiveError) { + await archive.finalize(); + } + } catch (err: any) { + console.error("[batch-pdf] error:", err); + if (!res.headersSent) { + return res.status(500).json({ error: err?.message ?? "Batch PDF download failed" }); + } + } + } +); + // GET /api/claims/by-appointment/:appointmentId // Returns the most recent active (non-cancelled/void) claim with service lines and files router.get( diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 8a4e3418..49a1c375 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -25,6 +25,7 @@ import { FileCheck, LoaderCircleIcon, Stethoscope, + Download, } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { Calendar } from "@/components/ui/calendar"; @@ -154,6 +155,8 @@ export default function AppointmentsPage() { const [selectedClaimColumns, setSelectedClaimColumns] = useState>(new Set()); const [isClaimingColumn, setIsClaimingColumn] = useState(false); const [selectedReminderColumns, setSelectedReminderColumns] = useState>(new Set()); + const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState>(new Set()); + const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false); const toggleReminderColumn = (staffId: number) => { setSelectedReminderColumns((prev) => { @@ -173,6 +176,15 @@ 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) @@ -1037,6 +1049,38 @@ export default function AppointmentsPage() { } }; + const handleDownloadClaimPdfs = async () => { + if (!user || selectedDownloadPdfColumns.size === 0) return; + const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(","); + setIsDownloadingClaimPdfs(true); + try { + const res = await apiRequest( + "GET", + `/api/claims/batch-pdf?date=${formattedSelectedDate}&staffIds=${staffIdsParam}` + ); + if (!res.ok) { + let errMsg = `Server error ${res.status}`; + try { const body = await res.json(); errMsg = body?.error ?? errMsg; } catch { /* ignore */ } + toast({ title: "Download failed", description: errMsg, variant: "destructive" }); + return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `claims_${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.` }); + } catch (err: any) { + toast({ title: "Download failed", description: err?.message ?? String(err), variant: "destructive" }); + } finally { + setIsDownloadingClaimPdfs(false); + } + }; + return (
))}
+ + {/* Download Claim PDF for Column section */} +
+ + {staffMembers.map((staff, index) => ( + + ))} +
diff --git a/apps/SeleniumService/selenium_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_eligibilityCheckWorker.py index c18bb93e..4510739f 100755 --- a/apps/SeleniumService/selenium_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_eligibilityCheckWorker.py @@ -180,14 +180,15 @@ class AutomationMassHealthEligibilityCheck: return ''.join(c for c in str(s) if c.isalnum()).lower() def _extract_data_from_page(self): - wait = WebDriverWait(self.driver, 5) + wait = WebDriverWait(self.driver, 15) extracted = {} try: - # Wait until Eligible or Ineligible header appears + # Wait until at least one table row appears under Eligible or Ineligible — + # the h4 header renders before the rows are populated, so we must wait for rows. wait.until( EC.presence_of_element_located( - (By.XPATH, "//h4[text()='Eligible' or text()='Ineligible']") + (By.XPATH, "//h4[text()='Eligible' or text()='Ineligible']/following::table[1]/tbody/tr") ) )