From 06526cd1bca81f7ac6c7ddd8b3e3ea09ee345824 Mon Sep 17 00:00:00 2001 From: Gitead Date: Wed, 13 May 2026 23:29:55 -0400 Subject: [PATCH] feat: MH eligibility & history, CMSP eligibility & history & remaining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MH Eligibility & History button: runs full MH eligibility flow then clicks member ID → service history, prints both PDFs via CDP, opens dual side-by-side PDF modal (eligibility auto-downloads, history does not) - Add CMSP Eligibility & History & Remaining button: same flow plus navigates back to member details, clicks View Accumulator, prints accumulator PDF via CDP; opens 3-panel side-by-side PDF modal - Generalize DualPdfPreviewModal to accept panels[] array (works for 2 or 3 PDFs) - Auto-download eligibility PDF via direct API URL to avoid Chrome Safe Browsing pause on blob: URL downloads - New backend processors, job types, and routes for both flows - New Python Selenium workers with stable CSS selectors (ng-bind, href*) Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/queue/jobRunner.ts | 22 + ...mspEligibilityHistoryRemainingProcessor.ts | 161 +++++++ .../processors/eligibilityHistoryProcessor.ts | 159 +++++++ apps/Backend/src/queue/queues.ts | 4 +- apps/Backend/src/routes/insuranceStatus.ts | 93 ++++ .../dual-pdf-preview-modal.tsx | 196 ++++++++ .../insurance-status/pdf-preview-modal.tsx | 15 + .../src/pages/insurance-status-page.tsx | 280 ++++++++++- apps/SeleniumService/agent.py | 79 ++++ ..._eligibilityHistoryRemainingCheckWorker.py | 443 ++++++++++++++++++ ...lenium_MH_eligibilityHistoryCheckWorker.py | 418 +++++++++++++++++ 11 files changed, 1868 insertions(+), 2 deletions(-) create mode 100644 apps/Backend/src/queue/processors/cmspEligibilityHistoryRemainingProcessor.ts create mode 100644 apps/Backend/src/queue/processors/eligibilityHistoryProcessor.ts create mode 100644 apps/Frontend/src/components/insurance-status/dual-pdf-preview-modal.tsx create mode 100644 apps/SeleniumService/selenium_CMSP_eligibilityHistoryRemainingCheckWorker.py create mode 100644 apps/SeleniumService/selenium_MH_eligibilityHistoryCheckWorker.py diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index e523070e..18399fc0 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -16,6 +16,8 @@ import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilit import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor"; import { runDentaQuestEligibilityProcessor } from "./processors/dentaQuestEligibilityProcessor"; import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor"; +import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor"; +import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor"; import type { SeleniumJobData, OcrJobData } from "./queues"; // ── Queue instances ────────────────────────────────────────────────────────── @@ -144,6 +146,26 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "cmsp-eligibility-history-remaining-check") { + return runCmspEligibilityHistoryRemainingProcessor({ + enrichedPayload: data.enrichedPayload, + userId: data.userId, + insuranceId: data.insuranceId!, + formFirstName: data.formFirstName, + formLastName: data.formLastName, + formDob: data.formDob, + }); + } + if (jobType === "mh-eligibility-history-check") { + return runEligibilityHistoryProcessor({ + enrichedPayload: data.enrichedPayload, + userId: data.userId, + insuranceId: data.insuranceId!, + formFirstName: data.formFirstName, + formLastName: data.formLastName, + formDob: data.formDob, + }); + } throw new Error(`Unknown selenium jobType: ${jobType}`); }); diff --git a/apps/Backend/src/queue/processors/cmspEligibilityHistoryRemainingProcessor.ts b/apps/Backend/src/queue/processors/cmspEligibilityHistoryRemainingProcessor.ts new file mode 100644 index 00000000..ca8905c7 --- /dev/null +++ b/apps/Backend/src/queue/processors/cmspEligibilityHistoryRemainingProcessor.ts @@ -0,0 +1,161 @@ +/** + * Processor for "cmsp-eligibility-history-remaining-check" jobs. + * Saves eligibility PDF, service history PDF, and accumulator PDF + * to the patient's ELIGIBILITY_STATUS document group. + */ +import fs from "fs/promises"; +import path from "path"; +import { storage } from "../../storage"; +import { emptyFolderContainingFile } from "../../utils/emptyTempFolder"; +import forwardToPatientDataExtractorService from "../../services/patientDataExtractorService"; +import { + callPythonSync, + splitName, + createOrUpdatePatientByInsuranceId, +} from "./_shared"; + +export interface CmspEligibilityHistoryRemainingProcessorInput { + enrichedPayload: any; + userId: number; + insuranceId: string; + formFirstName?: string; + formLastName?: string; + formDob?: string; +} + +export interface CmspEligibilityHistoryRemainingProcessorResult { + patientUpdateStatus?: string; + pdfUploadStatus?: string; + pdfFileId?: number | null; + pdfFilename?: string | null; + historyPdfFileId?: number | null; + historyPdfFilename?: string | null; + accumulatorPdfFileId?: number | null; + accumulatorPdfFilename?: string | null; +} + +export async function runCmspEligibilityHistoryRemainingProcessor( + input: CmspEligibilityHistoryRemainingProcessorInput +): Promise { + const { enrichedPayload, userId, insuranceId, formFirstName, formLastName, formDob } = input; + + const seleniumResult = await callPythonSync("/cmsp-eligibility-history-remaining-check", { + data: enrichedPayload, + }); + + const outputResult: CmspEligibilityHistoryRemainingProcessorResult = {}; + + // Extract patient name + const seleniumFirst = seleniumResult.firstName?.trim() || null; + const seleniumLast = seleniumResult.lastName?.trim() || null; + const seleniumName = seleniumResult.name?.trim() || null; + const seleniumInsurance = seleniumResult.insurance?.trim() || null; + + const extracted: { firstName?: string | null; lastName?: string | null } = {}; + + if (seleniumFirst || seleniumLast) { + extracted.firstName = seleniumFirst; + extracted.lastName = seleniumLast; + } else if (seleniumName) { + const parts = splitName(seleniumName); + extracted.firstName = parts.firstName || null; + extracted.lastName = parts.lastName || null; + } + + if (!extracted.firstName && !extracted.lastName && seleniumResult?.pdf_path?.endsWith(".pdf")) { + try { + const pdfBuffer = await fs.readFile(seleniumResult.pdf_path); + const extraction = await forwardToPatientDataExtractorService({ + buffer: pdfBuffer, + originalname: path.basename(seleniumResult.pdf_path), + mimetype: "application/pdf", + } as any); + if (extraction.name) { + const parts = splitName(extraction.name); + extracted.firstName = parts.firstName || null; + extracted.lastName = parts.lastName || null; + } + } catch (e) { + console.error("[cmspProcessor] PDF name extraction failed:", e); + } + } + + const preferFirst = extracted.firstName || formFirstName || null; + const preferLast = extracted.lastName || formLastName || null; + + let patient; + try { + patient = await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName: preferFirst, + lastName: preferLast, + dob: formDob, + userId, + }); + } catch (e: any) { + throw new Error(`Failed to create/update patient: ${e.message}`); + } + + if (patient && patient.id !== undefined) { + let newStatus = "UNKNOWN"; + if (seleniumResult.eligibility === "Y") newStatus = "ACTIVE"; + else if (seleniumResult.eligibility === "N") newStatus = "INACTIVE"; + + const updates: any = { status: newStatus }; + if (seleniumInsurance) updates.insuranceProvider = seleniumInsurance; + await storage.updatePatient(patient.id, updates); + outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + // Helper: save a single PDF to the patient's ELIGIBILITY_STATUS group + const saveToGroup = async (pdfPath: string): Promise => { + try { + const buffer = await fs.readFile(pdfPath); + const groupTitleKey = "ELIGIBILITY_STATUS"; + const groupTitle = "Eligibility Status"; + let group = await storage.findPdfGroupByPatientTitleKey(patient!.id, groupTitleKey); + if (!group) group = await storage.createPdfGroup(patient!.id, groupTitle, groupTitleKey); + if (!group?.id) throw new Error("PDF group creation failed"); + const created = await storage.createPdfFile(group.id, path.basename(pdfPath), buffer); + if (created && typeof created === "object" && "id" in created) { + return Number(created.id); + } + return null; + } catch (e: any) { + console.error("[cmspProcessor] saveToGroup failed:", e.message); + return null; + } + }; + + // Save eligibility PDF + if (seleniumResult.pdf_path?.endsWith(".pdf")) { + outputResult.pdfFileId = await saveToGroup(seleniumResult.pdf_path); + outputResult.pdfFilename = path.basename(seleniumResult.pdf_path); + outputResult.pdfUploadStatus = outputResult.pdfFileId ? "Eligibility PDF saved" : "Eligibility PDF upload failed"; + } else { + outputResult.pdfUploadStatus = "No eligibility PDF returned by Selenium"; + } + + // Save history PDF + if (seleniumResult.history_pdf_path?.endsWith(".pdf")) { + outputResult.historyPdfFileId = await saveToGroup(seleniumResult.history_pdf_path); + outputResult.historyPdfFilename = path.basename(seleniumResult.history_pdf_path); + } + + // Save accumulator PDF + if (seleniumResult.accumulator_pdf_path?.endsWith(".pdf")) { + outputResult.accumulatorPdfFileId = await saveToGroup(seleniumResult.accumulator_pdf_path); + outputResult.accumulatorPdfFilename = path.basename(seleniumResult.accumulator_pdf_path); + } + } else { + outputResult.patientUpdateStatus = "Patient not found or missing ID; no update performed"; + } + + // Cleanup temp files + try { + if (seleniumResult.pdf_path) await emptyFolderContainingFile(seleniumResult.pdf_path); + } catch (e) { + console.error("[cmspProcessor] cleanup failed:", e); + } + + return outputResult; +} diff --git a/apps/Backend/src/queue/processors/eligibilityHistoryProcessor.ts b/apps/Backend/src/queue/processors/eligibilityHistoryProcessor.ts new file mode 100644 index 00000000..36ad9f3a --- /dev/null +++ b/apps/Backend/src/queue/processors/eligibilityHistoryProcessor.ts @@ -0,0 +1,159 @@ +/** + * Processor for "mh-eligibility-history-check" jobs. + * Runs MH eligibility + service history selenium flow and saves both PDFs + * to the patient's ELIGIBILITY_STATUS document group. + */ +import fs from "fs/promises"; +import path from "path"; +import { storage } from "../../storage"; +import { emptyFolderContainingFile } from "../../utils/emptyTempFolder"; +import forwardToPatientDataExtractorService from "../../services/patientDataExtractorService"; +import { + callPythonSync, + splitName, + createOrUpdatePatientByInsuranceId, +} from "./_shared"; + +export interface EligibilityHistoryProcessorInput { + enrichedPayload: any; + userId: number; + insuranceId: string; + formFirstName?: string; + formLastName?: string; + formDob?: string; +} + +export interface EligibilityHistoryProcessorResult { + patientUpdateStatus?: string; + pdfUploadStatus?: string; + pdfFileId?: number | null; + historyPdfFileId?: number | null; + pdfFilename?: string | null; + historyPdfFilename?: string | null; +} + +export async function runEligibilityHistoryProcessor( + input: EligibilityHistoryProcessorInput +): Promise { + const { enrichedPayload, userId, insuranceId, formFirstName, formLastName, formDob } = input; + + const seleniumResult = await callPythonSync("/mh-eligibility-history-check", { + data: enrichedPayload, + }); + + const outputResult: EligibilityHistoryProcessorResult = {}; + + // Extract patient name + const seleniumFirst = seleniumResult.firstName?.trim() || null; + const seleniumLast = seleniumResult.lastName?.trim() || null; + const seleniumName = seleniumResult.name?.trim() || null; + const seleniumInsurance = seleniumResult.insurance?.trim() || null; + + const extracted: { firstName?: string | null; lastName?: string | null } = {}; + + if (seleniumFirst || seleniumLast) { + extracted.firstName = seleniumFirst; + extracted.lastName = seleniumLast; + } else if (seleniumName) { + const parts = splitName(seleniumName); + extracted.firstName = parts.firstName || null; + extracted.lastName = parts.lastName || null; + } + + if (!extracted.firstName && !extracted.lastName && seleniumResult?.pdf_path?.endsWith(".pdf")) { + try { + const pdfBuffer = await fs.readFile(seleniumResult.pdf_path); + const extraction = await forwardToPatientDataExtractorService({ + buffer: pdfBuffer, + originalname: path.basename(seleniumResult.pdf_path), + mimetype: "application/pdf", + } as any); + if (extraction.name) { + const parts = splitName(extraction.name); + extracted.firstName = parts.firstName || null; + extracted.lastName = parts.lastName || null; + } + } catch (e) { + console.error("[eligibilityHistoryProcessor] PDF name extraction failed:", e); + } + } + + const preferFirst = extracted.firstName || formFirstName || null; + const preferLast = extracted.lastName || formLastName || null; + + let patient; + try { + patient = await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName: preferFirst, + lastName: preferLast, + dob: formDob, + userId, + }); + } catch (e: any) { + throw new Error(`Failed to create/update patient: ${e.message}`); + } + + if (patient && patient.id !== undefined) { + let newStatus = "UNKNOWN"; + if (seleniumResult.eligibility === "Y") newStatus = "ACTIVE"; + else if (seleniumResult.eligibility === "N") newStatus = "INACTIVE"; + + const updates: any = { status: newStatus }; + if (seleniumInsurance) updates.insuranceProvider = seleniumInsurance; + await storage.updatePatient(patient.id, updates); + outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + // Helper: save a PDF buffer to the patient's ELIGIBILITY_STATUS group + const saveToGroup = async (pdfPath: string): Promise => { + try { + const buffer = await fs.readFile(pdfPath); + const groupTitleKey = "ELIGIBILITY_STATUS"; + const groupTitle = "Eligibility Status"; + let group = await storage.findPdfGroupByPatientTitleKey(patient!.id, groupTitleKey); + if (!group) group = await storage.createPdfGroup(patient!.id, groupTitle, groupTitleKey); + if (!group?.id) throw new Error("PDF group creation failed"); + const created = await storage.createPdfFile(group.id, path.basename(pdfPath), buffer); + if (created && typeof created === "object" && "id" in created) { + return Number(created.id); + } + return null; + } catch (e: any) { + console.error("[eligibilityHistoryProcessor] saveToGroup failed:", e.message); + return null; + } + }; + + // Save eligibility PDF + let eligibilityPdfFileId: number | null = null; + if (seleniumResult.pdf_path?.endsWith(".pdf")) { + eligibilityPdfFileId = await saveToGroup(seleniumResult.pdf_path); + outputResult.pdfUploadStatus = eligibilityPdfFileId + ? "Eligibility PDF saved" + : "Eligibility PDF upload failed"; + outputResult.pdfFilename = path.basename(seleniumResult.pdf_path); + } else { + outputResult.pdfUploadStatus = "No eligibility PDF returned by Selenium"; + } + outputResult.pdfFileId = eligibilityPdfFileId; + + // Save history PDF + let historyPdfFileId: number | null = null; + if (seleniumResult.history_pdf_path?.endsWith(".pdf")) { + historyPdfFileId = await saveToGroup(seleniumResult.history_pdf_path); + outputResult.historyPdfFilename = path.basename(seleniumResult.history_pdf_path); + } + outputResult.historyPdfFileId = historyPdfFileId; + } else { + outputResult.patientUpdateStatus = "Patient not found or missing ID; no update performed"; + } + + // Cleanup temp files + try { + if (seleniumResult.pdf_path) await emptyFolderContainingFile(seleniumResult.pdf_path); + } catch (e) { + console.error("[eligibilityHistoryProcessor] cleanup failed:", e); + } + + return outputResult; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index 2d865a95..8005dc31 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -10,7 +10,9 @@ export type SeleniumJobType = | "ddma-eligibility-check" | "deltains-eligibility-check" | "unitedsco-eligibility-check" - | "cca-eligibility-check"; + | "cca-eligibility-check" + | "mh-eligibility-history-check" + | "cmsp-eligibility-history-remaining-check"; export interface SeleniumJobData { jobType: SeleniumJobType; diff --git a/apps/Backend/src/routes/insuranceStatus.ts b/apps/Backend/src/routes/insuranceStatus.ts index 481cfb42..f6b4b182 100755 --- a/apps/Backend/src/routes/insuranceStatus.ts +++ b/apps/Backend/src/routes/insuranceStatus.ts @@ -15,6 +15,7 @@ import { } from "../../../../packages/db/types/patient-types"; import { formatDobForAgent } from "../utils/dateUtils"; import { seleniumQueue } from "../queue/queues"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; const router = Router(); @@ -166,6 +167,98 @@ router.post( } ); +router.post( + "/eligibility-history-check", + async (req: Request, res: Response): Promise => { + if (!req.body.data) { + return res.status(400).json({ error: "Missing Insurance Eligibility data for selenium" }); + } + if (!req.user || !req.user.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + const data = + typeof req.body.data === "string" ? JSON.parse(req.body.data) : req.body.data; + + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(req.user.id, "MH"); + if (!credentials) { + return res.status(404).json({ + error: "No MassHealth credentials found. Please update them in Settings.", + }); + } + + const insuranceId = String(data.memberId ?? "").trim(); + if (!insuranceId) { + return res.status(400).json({ error: "Missing memberId" }); + } + + const enrichedData = { + ...data, + massdhpUsername: credentials.username, + massdhpPassword: credentials.password, + }; + + const jobId = enqueueSeleniumJob({ + jobType: "mh-eligibility-history-check", + userId: req.user.id, + socketId: req.body.socketId, + enrichedPayload: enrichedData, + insuranceId, + formFirstName: data.firstName, + formLastName: data.lastName, + formDob: data.dateOfBirth, + }); + + return res.json({ jobId, status: "queued" }); + } +); + +router.post( + "/cmsp-eligibility-history-remaining-check", + async (req: Request, res: Response): Promise => { + if (!req.body.data) { + return res.status(400).json({ error: "Missing data for selenium" }); + } + if (!req.user || !req.user.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + const data = + typeof req.body.data === "string" ? JSON.parse(req.body.data) : req.body.data; + + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(req.user.id, "MH"); + if (!credentials) { + return res.status(404).json({ + error: "No MassHealth credentials found. Please update them in Settings.", + }); + } + + const insuranceId = String(data.memberId ?? "").trim(); + if (!insuranceId) { + return res.status(400).json({ error: "Missing memberId" }); + } + + const enrichedData = { + ...data, + massdhpUsername: credentials.username, + massdhpPassword: credentials.password, + }; + + const jobId = enqueueSeleniumJob({ + jobType: "cmsp-eligibility-history-remaining-check", + userId: req.user.id, + socketId: req.body.socketId, + enrichedPayload: enrichedData, + insuranceId, + formFirstName: data.firstName, + formLastName: data.lastName, + formDob: data.dateOfBirth, + }); + + return res.json({ jobId, status: "queued" }); + } +); + router.post( "/claim-status-check", async (req: Request, res: Response): Promise => { diff --git a/apps/Frontend/src/components/insurance-status/dual-pdf-preview-modal.tsx b/apps/Frontend/src/components/insurance-status/dual-pdf-preview-modal.tsx new file mode 100644 index 00000000..3940d65d --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/dual-pdf-preview-modal.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { apiRequest } from "@/lib/queryClient"; + +export interface PdfPanelConfig { + pdfId?: number | null; + fallbackFilename?: string | null; + label: string; + autoDownload?: boolean; +} + +interface Props { + open: boolean; + onClose: () => void; + panels: PdfPanelConfig[]; + title?: string; +} + +function parseFilename(header: string | null): string | null { + if (!header) return null; + const starMatch = header.match(/filename\*\s*=\s*([^;]+)/i); + if (starMatch?.[1]) { + const raw = starMatch[1].trim().replace(/^"(.*)"$/, "$1"); + const parts = raw.split("''"); + if (parts.length === 2 && parts[1]) { + try { return decodeURIComponent(parts[1]); } catch { return parts[1]; } + } + try { return decodeURIComponent(raw); } catch { return raw; } + } + const quoted = header.match(/filename\s*=\s*"([^"]+)"/i); + if (quoted?.[1]) return quoted[1].trim(); + const plain = header.match(/filename\s*=\s*([^;]+)/i); + if (plain?.[1]) return plain[1].trim().replace(/^"(.*)"$/, "$1"); + return null; +} + +function usePdfBlob(open: boolean, pdfId?: number | null, fallbackFilename?: string | null) { + const [blobUrl, setBlobUrl] = useState(null); + const [filename, setFilename] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !pdfId) return; + + let objectUrl: string | null = null; + let aborted = false; + + (async () => { + setLoading(true); + setError(null); + try { + const res = await apiRequest("GET", `/api/documents/pdf-files/${pdfId}`); + if (!res?.ok) { + const txt = await res?.text().catch(() => ""); + throw new Error(txt || `Failed to fetch PDF: ${res?.status}`); + } + const header = res.headers?.get?.("content-disposition") ?? null; + const finalName = parseFilename(header) ?? fallbackFilename ?? `file_${pdfId}.pdf`; + setFilename(finalName); + + const buf = await res.arrayBuffer(); + if (aborted) return; + + const blob = new Blob([buf], { type: "application/pdf" }); + objectUrl = URL.createObjectURL(blob); + setBlobUrl(objectUrl); + } catch (e: any) { + if (e?.name === "AbortError") return; + setError(e?.message ?? "Failed to fetch PDF"); + } finally { + setLoading(false); + } + })(); + + return () => { + aborted = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + setBlobUrl(null); + setError(null); + setLoading(false); + setFilename(null); + }; + }, [open, pdfId, fallbackFilename]); + + return { blobUrl, filename, loading, error }; +} + +function PdfPanel({ config, open }: { config: PdfPanelConfig; open: boolean }) { + const { blobUrl, filename, loading, error } = usePdfBlob( + open, + config.pdfId, + config.fallbackFilename + ); + + // Auto-download via direct API URL to avoid Chrome Safe Browsing pause + useEffect(() => { + if (!config.autoDownload || !config.pdfId || !filename) return; + const a = document.createElement("a"); + a.href = `/api/documents/pdf-files/${config.pdfId}`; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }, [config.autoDownload, config.pdfId, filename]); + + const handleDownload = () => { + if (!config.pdfId || !filename) return; + const a = document.createElement("a"); + a.href = `/api/documents/pdf-files/${config.pdfId}`; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + return ( +
+
+
+ + {config.label} + + + {filename ?? "Loading…"} + +
+ +
+ +
+ {loading && ( +
+ Loading PDF… +
+ )} + {error && ( +
+ Error: {error} +
+ )} + {!config.pdfId && !loading && ( +
+ No PDF available +
+ )} + {blobUrl && ( +