feat: add new frontend components, MH batch worker, and gitignore rules

- Add all new Frontend source files (pages, components, hooks, utils)
- Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py
- Add install-steps-5-13.sh setup script
- Update .gitignore to exclude runtime/sensitive data (backups, uploads,
  chat-history, keys, downloads, generated .d.ts files) while keeping folders
- Add .gitkeep to preserve empty runtime folders in git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:23:43 -04:00
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function BcbsMaOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP BCBS MA</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
Enter the last 6 digits of the one-time verification code sent by the BCBS MA Provider
Central portal to your registered email. The email shows a code like{" "}
<span className="font-mono font-medium">XXXX-XXXXXX</span> enter only the last 6 digits.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bcbs-ma-otp">Last 6 digits of OTP</Label>
<Input id="bcbs-ma-otp" placeholder="e.g. 482913" value={otp} onChange={(e) => setOtp(e.target.value)} maxLength={6} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit OTP")}
</Button>
</div>
</form>
</div>
</div>);
}
export function BcbsMaEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "BCBS_MA",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Starting BCBS MA eligibility check…" }));
const response = await apiRequest("POST", "/api/insurance-status-bcbs-ma/bcbs-ma-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "BCBS MA job queued. Opening browser…" }));
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Browser started. Waiting for OTP…" }));
};
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "OTP required for BCBS MA. Please enter the code." }));
};
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "OTP submitted. Finishing BCBS MA eligibility check…" }));
};
function cleanup() {
socket.off("selenium:bcbs_ma_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: "BCBS MA job timed out." }));
}, 10 * 60 * 1000);
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: data.message ?? "Browser starting…" }));
return;
}
clearTimeout(safetyTimer);
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "BCBS MA eligibility updated and PDF saved." }));
toast({ title: "BCBS MA eligibility complete", description: "Patient status was updated and the eligibility PDF was saved." });
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "BCBS MA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "BCBS MA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("selenium:bcbs_ma_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
socket.on("job:update", onJobUpdate);
}
catch (err) {
console.error("BcbsMaEligibilityButton error:", err);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: err?.message || "Failed to start BCBS MA eligibility" }));
toast({ title: "BCBS MA selenium error", description: err?.message || "Failed to start BCBS MA eligibility", variant: "destructive" });
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({ title: "Session not ready", description: "Cannot submit OTP — session ID not yet available.", variant: "destructive" });
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-bcbs-ma/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error)
throw new Error(data.error || "Failed to submit OTP");
setOtpModalOpen(false);
}
catch (err) {
toast({ title: "Failed to submit OTP", description: err?.message || "Error forwarding OTP to selenium agent", variant: "destructive" });
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
BCBS MA
</>)}
</Button>
<BcbsMaOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle, LoaderCircleIcon } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
export function CCAEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [isStarting, setIsStarting] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "CCA",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting CCA eligibility check…",
}));
const response = await apiRequest("POST", "/api/insurance-status-cca/cca-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "CCA job queued. Waiting for browser session…",
}));
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Running eligibility check…",
}));
};
socket.on("selenium:cca_session_started", onSessionStarted);
function cleanup() {
clearTimeout(safetyTimer);
socket.off("selenium:cca_session_started", onSessionStarted);
socket.off("job:update", onJobUpdate);
}
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "CCA eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "CCA eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_cca_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "CCA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "CCA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
};
socket.on("job:update", onJobUpdate);
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "CCA job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
}
catch (err) {
console.error("CCAEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start CCA eligibility",
}));
toast({
title: "CCA selenium error",
description: err?.message || "Failed to start CCA eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
CCA
</>)}
</Button>);
}

View File

@@ -0,0 +1,327 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function DdmaOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent by the Delta Dental MA portal to complete this
eligibility check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ddma-otp">OTP</Label>
<Input id="ddma-otp" placeholder="Enter OTP code" value={otp} onChange={(e) => setOtp(e.target.value)} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit OTP")}
</Button>
</div>
</form>
</div>
</div>);
}
export function DdmaEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
// ── Socket event handlers ─────────────────────────────────────────────────
const handleDdmaStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "DDMA",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting DDMA eligibility check…",
}));
// 1) POST to backend — returns { status: "queued", jobId }
const response = await apiRequest("POST", "/api/insurance-status-ddma/ddma-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "DDMA job queued. Waiting for browser session to start…",
}));
// 2) Listen for job-lifecycle and DDMA-specific socket events.
// All events come through the shared app socket.
// Handler: Python agent started a browser session → we now have session_id
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Waiting for OTP or result…",
}));
};
// Handler: OTP is required by the DDMA portal
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
// Update sessionId in case it arrives here first
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP required for Delta Dental MA. Please enter the code.",
}));
};
// Handler: OTP accepted by Python agent (optional UX feedback)
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP submitted. Finishing DDMA eligibility check…",
}));
};
// Handler: job completed or failed (from InProcessQueue)
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
// Terminal states
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "DDMA eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "DDMA eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
const filename = data.result?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`;
onPdfReady(Number(pdfId), filename);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "DDMA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "DDMA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
// Attach listeners
socket.on("selenium:ddma_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
socket.on("job:update", onJobUpdate);
// Cleanup helper removes all listeners for this job
function cleanup() {
socket.off("selenium:ddma_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
// Safety timeout — clean up listeners if no terminal event in 6 min
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "DDMA job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
// Patch cleanup to also clear the timer
const originalCleanup = cleanup;
function cleanupWithTimer() {
clearTimeout(safetyTimer);
originalCleanup();
}
// Override the onJobUpdate cleanup reference
socket.off("job:update", onJobUpdate);
socket.on("job:update", (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanupWithTimer();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "DDMA eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "DDMA eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "DDMA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "DDMA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
});
}
catch (err) {
console.error("DdmaEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start DDMA eligibility",
}));
toast({
title: "DDMA selenium error",
description: err?.message || "Failed to start DDMA eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
// ── OTP submission ────────────────────────────────────────────────────────
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit OTP — DDMA session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-ddma/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit OTP");
}
setOtpModalOpen(false);
}
catch (err) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit OTP",
description: err?.message || "Error forwarding OTP to selenium agent",
variant: "destructive",
});
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleDdmaStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
// ── Render ────────────────────────────────────────────────────────────────
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleDdmaStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
Delta MA
</>)}
</Button>
<DdmaOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,270 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function DeltaInsOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent by the Delta Dental Ins portal to your email.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="deltains-otp">OTP</Label>
<Input id="deltains-otp" placeholder="Enter OTP code" value={otp} onChange={(e) => setOtp(e.target.value)} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit OTP")}
</Button>
</div>
</form>
</div>
</div>);
}
export function DeltaInsEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "DELTAINS",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting Delta Ins eligibility check…",
}));
const response = await apiRequest("POST", "/api/insurance-status-deltains/deltains-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Delta Ins job queued. Waiting for browser session to start…",
}));
// Handler: Python agent started a browser session
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Waiting for OTP or result…",
}));
};
// Handler: OTP required
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP required for Delta Dental Ins. Please enter the code from your email.",
}));
};
// Handler: OTP accepted
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP submitted. Finishing Delta Ins eligibility check…",
}));
};
socket.on("selenium:deltains_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
function cleanup() {
clearTimeout(safetyTimer);
socket.off("selenium:deltains_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "Delta Ins eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "Delta Ins eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_deltains_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "Delta Ins eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "Delta Ins selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("job:update", onJobUpdate);
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "Delta Ins job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
}
catch (err) {
console.error("DeltaInsEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start Delta Ins eligibility",
}));
toast({
title: "Delta Ins selenium error",
description: err?.message || "Failed to start Delta Ins eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit OTP — Delta Ins session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-deltains/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit OTP");
}
setOtpModalOpen(false);
}
catch (err) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit OTP",
description: err?.message || "Error forwarding OTP to selenium agent",
variant: "destructive",
});
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
Deltains
</>)}
</Button>
<DeltaInsOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
function parseFilename(header) {
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, pdfId, fallbackFilename) {
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 = 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) {
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 }) {
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 }) {
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>);
}

View File

@@ -0,0 +1,174 @@
// src/components/insurance-status/pdf-preview-modal.tsx
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import { Maximize2, Minimize2 } from "lucide-react";
function parseFilenameFromContentDisposition(header) {
if (!header)
return null;
const filenameStarMatch = header.match(/filename\*\s*=\s*([^;]+)/i);
if (filenameStarMatch && filenameStarMatch[1]) {
let raw = filenameStarMatch[1].trim();
raw = raw.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 filenameMatchQuoted = header.match(/filename\s*=\s*"([^"]+)"/i);
if (filenameMatchQuoted && filenameMatchQuoted[1]) {
return filenameMatchQuoted[1].trim();
}
const filenameMatch = header.match(/filename\s*=\s*([^;]+)/i);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1].trim().replace(/^"(.*)"$/, "$1");
}
return null;
}
export function PdfPreviewModal({ open, onClose, pdfId, fallbackFilename = null, autoDownload = false, }) {
const [fileBlobUrl, setFileBlobUrl] = useState(null);
const [isImage, setIsImage] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [resolvedFilename, setResolvedFilename] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!open)
return;
let objectUrl = null;
const controller = new AbortController();
let aborted = false;
const fetchPdf = async () => {
if (!pdfId) {
setError("No PDF id provided.");
return;
}
setLoading(true);
setError(null);
setResolvedFilename(null);
try {
const res = await apiRequest("GET", `/api/documents/pdf-files/${pdfId}`);
if (!res) {
throw new Error("No response from server");
}
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to fetch PDF: ${res.status}`);
}
const contentDispHeader = res.headers?.get?.("content-disposition") ??
res.headers?.get?.("Content-Disposition") ??
null;
const parsedFilename = parseFilenameFromContentDisposition(contentDispHeader);
const finalName = parsedFilename ?? fallbackFilename ?? `file_${pdfId}.pdf`;
setResolvedFilename(finalName);
const arrayBuffer = await res.arrayBuffer();
if (aborted)
return;
const lowerName = finalName.toLowerCase();
const isPng = lowerName.endsWith(".png");
const isJpg = lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg");
const mimeType = isPng ? "image/png" : isJpg ? "image/jpeg" : "application/pdf";
const blob = new Blob([arrayBuffer], { type: mimeType });
objectUrl = URL.createObjectURL(blob);
setIsImage(isPng || isJpg);
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) {
if (err && (err.name === "AbortError" || err.message === "The user aborted a request.")) {
return;
}
console.error("PdfPreviewModal fetch error:", err);
setError(err?.message ?? "Failed to fetch PDF");
}
finally {
setLoading(false);
}
};
fetchPdf();
return () => {
aborted = true;
controller.abort();
if (objectUrl)
URL.revokeObjectURL(objectUrl);
setFileBlobUrl(null);
setIsImage(false);
setError(null);
setLoading(false);
setResolvedFilename(null);
setIsFullscreen(false);
};
}, [open, pdfId, fallbackFilename]);
if (!open)
return null;
const handleDownload = () => {
if (!fileBlobUrl)
return;
const a = document.createElement("a");
a.href = fileBlobUrl;
a.download = resolvedFilename ?? `file_${pdfId ?? "unknown"}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const wrapperClass = isFullscreen
? "fixed inset-0 z-50 flex items-center justify-center bg-black/80"
: "fixed inset-0 z-50 flex items-center justify-center bg-black/50";
const containerClass = isFullscreen
? "bg-white w-full h-full rounded-none m-0 shadow-none flex flex-col"
: "bg-white rounded-lg shadow-lg w-11/12 md:w-3/4 lg:w-4/5 xl:w-3/4 h-5/6 flex flex-col";
return (<div className={wrapperClass}>
<div className={containerClass}>
<div className="flex items-center justify-between p-3 md:p-4 border-b">
<div className="flex flex-col">
<h3 className="text-lg md:text-xl font-semibold">
{resolvedFilename ?? "PDF Preview"}
</h3>
<p className="text-sm text-muted-foreground">{pdfId ? `ID: ${pdfId}` : ""}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsFullscreen((s) => !s)} title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}>
{isFullscreen ? <Minimize2 className="w-4 h-4"/> : <Maximize2 className="w-4 h-4"/>}
</Button>
<Button variant="ghost" onClick={handleDownload} disabled={!fileBlobUrl || loading}>
Download
</Button>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-2 md:p-4">
{loading && <div>Loading PDF</div>}
{error && <div className="text-destructive">Error: {error}</div>}
{fileBlobUrl && (isImage ? (<img src={fileBlobUrl} alt={resolvedFilename ?? "Preview"} className="max-w-full max-h-full object-contain mx-auto"/>) : (<iframe title="PDF Preview" src={fileBlobUrl} className="w-full h-full border" style={{ minHeight: 0 }}/>))}
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function TuftsSCOOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the verification code sent to your phone or email to complete this check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tufts-sco-otp">Verification Code</Label>
<Input id="tufts-sco-otp" placeholder="Enter verification code" value={otp} onChange={(e) => setOtp(e.target.value)} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit Code")}
</Button>
</div>
</form>
</div>
</div>);
}
export function TuftsSCOEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "TUFTS_SCO",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting Tufts SCO eligibility check…",
}));
const response = await apiRequest("POST", "/api/insurance-status-tuftssco/tuftssco-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Tufts SCO job queued. Waiting for browser session…",
}));
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Waiting for verification code or result…",
}));
};
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Verification code required. Please enter the code.",
}));
};
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Code submitted. Finishing eligibility check…",
}));
};
socket.on("selenium:dentaquest_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
function cleanup() {
clearTimeout(safetyTimer);
socket.off("selenium:dentaquest_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "Tufts SCO eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "Tufts SCO eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "Tufts SCO eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "Tufts SCO selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("job:update", onJobUpdate);
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "Tufts SCO job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
}
catch (err) {
console.error("TuftsSCOEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start Tufts SCO eligibility",
}));
toast({
title: "Tufts SCO selenium error",
description: err?.message || "Failed to start Tufts SCO eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit code — session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-tuftssco/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit code");
}
setOtpModalOpen(false);
}
catch (err) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit code",
description: err?.message || "Error forwarding code to selenium agent",
variant: "destructive",
});
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
Tufts SCO/SWH/Navi/Mass Gen
</>)}
</Button>
<TuftsSCOOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function UnitedSCOOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the verification code sent to your phone or email to complete this check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="united-sco-otp">Verification Code</Label>
<Input id="united-sco-otp" placeholder="Enter verification code" value={otp} onChange={(e) => setOtp(e.target.value)} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit Code")}
</Button>
</div>
</form>
</div>
</div>);
}
export function UnitedSCOEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "UNITED_SCO",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting United SCO eligibility check…",
}));
const response = await apiRequest("POST", "/api/insurance-status-unitedsco/unitedsco-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "United SCO job queued. Waiting for browser session…",
}));
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Waiting for verification code or result…",
}));
};
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Verification code required. Please enter the code.",
}));
};
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Code submitted. Finishing eligibility check…",
}));
};
socket.on("selenium:unitedsco_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
function cleanup() {
clearTimeout(safetyTimer);
socket.off("selenium:unitedsco_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "United SCO eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "United SCO eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "United SCO eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "United SCO selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("job:update", onJobUpdate);
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "United SCO job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
}
catch (err) {
console.error("UnitedSCOEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start United SCO eligibility",
}));
toast({
title: "United SCO selenium error",
description: err?.message || "Failed to start United SCO eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit code — session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-unitedsco/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit code");
}
setOtpModalOpen(false);
}
catch (err) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit code",
description: err?.message || "Error forwarding code to selenium agent",
variant: "destructive",
});
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
United SCO
</>)}
</Button>
<UnitedSCOOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}