feat: MH eligibility & history, CMSP eligibility & history & remaining
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [filename, setFilename] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col flex-1 min-w-0 border-r last:border-r-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-gray-50 shrink-0">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate" title={filename ?? undefined}>
|
||||
{filename ?? "Loading…"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={!config.pdfId || !filename}
|
||||
className="shrink-0 ml-2"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-2">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Loading PDF…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full text-sm text-destructive">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
{!config.pdfId && !loading && (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
No PDF available
|
||||
</div>
|
||||
)}
|
||||
{blobUrl && (
|
||||
<iframe
|
||||
title={config.label}
|
||||
src={blobUrl}
|
||||
className="w-full h-full border rounded"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DualPdfPreviewModal({ open, onClose, panels, title }: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-lg flex flex-col"
|
||||
style={{ width: "92vw", height: "88vh", maxWidth: 1600 }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<h3 className="text-base font-semibold">
|
||||
{title ?? "PDF Preview"}
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0 divide-x">
|
||||
{panels.map((panel, i) => (
|
||||
<PdfPanel key={i} config={panel} open={open} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
onClose: () => void;
|
||||
pdfId?: number | null;
|
||||
fallbackFilename?: string | null;
|
||||
autoDownload?: boolean;
|
||||
}
|
||||
|
||||
function parseFilenameFromContentDisposition(header: string | null): string | null {
|
||||
@@ -50,6 +51,7 @@ export function PdfPreviewModal({
|
||||
onClose,
|
||||
pdfId,
|
||||
fallbackFilename = null,
|
||||
autoDownload = false,
|
||||
}: Props) {
|
||||
const [fileBlobUrl, setFileBlobUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -99,6 +101,19 @@ export function PdfPreviewModal({
|
||||
const blob = new Blob([arrayBuffer], { type: "application/pdf" });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setFileBlobUrl(objectUrl);
|
||||
|
||||
if (autoDownload) {
|
||||
const a = document.createElement("a");
|
||||
// Use the direct API URL so Chrome sees a proper HTTP response with
|
||||
// Content-Disposition: attachment headers, which bypasses the Safe
|
||||
// Browsing pause that blob: URL downloads trigger on Linux/Chrome.
|
||||
a.href = `/api/documents/pdf-files/${pdfId}`;
|
||||
a.download = finalName;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err && (err.name === "AbortError" || err.message === "The user aborted a request.")) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user