feat: DDMA claim submission with OTP, PDF, claim number extraction

- Add full DDMA claim Selenium flow (steps 1-8): search patient, open
  member page, create claim, fill form, attach files, next, submit,
  extract claim number and save confirmation PDF
- Add fee schedule price-mismatch dialog for all claim buttons (MH,
  CCA, DDMA, United, Tufts, Save) with optional price update to JSON
- Add OTP modal for DDMA claim when session expires, mirroring
  eligibility OTP flow
- Close Chrome after claim submission via quit_driver() (session
  preserved in profile)
- Move Map Price button between Direct Submission and procedure table,
  right-aligned above Billed Amount column
- Add fee-schedule update-price backend route
- Add DDMA claim processor with claimNumber/pdf_url result handling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-24 13:35:04 -04:00
parent 5ceecbeb7f
commit cd1381e9c6
13 changed files with 2139 additions and 22 deletions

View File

@@ -51,6 +51,8 @@ import {
mapPricesForForm,
applyComboToForm,
getDescriptionForCode,
findPriceMismatches,
type PriceMismatch,
} from "@/utils/procedureCombosMapping";
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
import { DateInput } from "../ui/dateInput";
@@ -61,6 +63,16 @@ import {
RegularComboButtons,
} from "@/components/procedure/procedure-combo-buttons";
import { Switch } from "@/components/ui/switch";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface ClaimFormProps {
patientId: number;
@@ -77,6 +89,7 @@ interface ClaimFormProps {
onHandleForMHSeleniumClaimPreAuth: (data: ClaimPreAuthData) => void;
onHandleForCCASeleniumClaim: (data: ClaimFormData) => void;
onHandleForCCASeleniumPreAuth: (data: ClaimPreAuthData) => void;
onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void;
onClose: () => void;
}
@@ -91,6 +104,7 @@ export function ClaimForm({
onHandleForMHSeleniumClaimPreAuth,
onHandleForCCASeleniumClaim,
onHandleForCCASeleniumPreAuth,
onHandleForDDMASeleniumClaim,
onSubmit,
onClose,
}: ClaimFormProps) {
@@ -601,7 +615,7 @@ export function ClaimForm({
if (!p) return "";
if (p.includes("masshealth") || p === "mh" || p === "mass health") return "MH";
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
if (p.includes("ddma")) return "DDMA";
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
if (p.includes("united sco") || p === "unitedsco") return "UnitedSCO";
@@ -717,6 +731,8 @@ export function ClaimForm({
// FILE UPLOAD ZONE
const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [priceMismatches, setPriceMismatches] = useState<PriceMismatch[]>([]);
const pendingClaimAction = useRef<(() => void) | null>(null);
// NO validation here — the upload zone handles validation, toasts, max files, sizes, etc.
const handleFilesChange = useCallback((files: File[]) => {
@@ -976,6 +992,84 @@ export function ClaimForm({
onClose();
};
// Delta MA Claim: saves to DB then submits via Selenium
const handleDDMAClaim = async () => {
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
title: "Missing Required Fields",
description: `Please fill out the following field(s): ${missingFields.join(", ")}`,
variant: "destructive",
});
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
toast({
title: "No procedure codes",
description: "Please add at least one procedure code before submitting the claim.",
variant: "destructive",
});
return;
}
let appointmentIdToUse = appointmentId;
if (appointmentIdToUse == null) {
const created = await onHandleAppointmentSubmit({
patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
});
if (typeof created === "number" && created > 0) {
appointmentIdToUse = created;
} else if (created && typeof (created as any).id === "number") {
appointmentIdToUse = (created as any).id;
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
// Upload files to server so we get local filePaths for Selenium
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(uploadedFiles)
: [];
const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
: null;
const createdClaim = await onSubmit({
...formToCreateClaim,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
insuranceProvider: "Delta Dental MA",
appointmentId: appointmentIdToUse!,
claimFiles: claimFilesMeta,
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
});
onHandleForDDMASeleniumClaim({
...form,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
insuranceProvider: "Delta Dental MA",
appointmentId: appointmentIdToUse!,
insuranceSiteKey: "DDMA",
claimId: createdClaim.id,
claimFiles: claimFilesMeta,
});
onClose();
};
const handleCCAPreAuth = async () => {
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
@@ -1014,6 +1108,37 @@ export function ClaimForm({
onClose();
};
/** Check prices against the fee schedule. If mismatches exist, show dialog and
* store the action to run after the user responds. Otherwise run immediately. */
const runWithPriceCheck = (action: () => void) => {
const siteKey = form.insuranceSiteKey || deriveInsuranceSiteKey(form.insuranceProvider || "");
const mismatches = findPriceMismatches(
(form.serviceLines || []).filter(l => (l.procedureCode || "").trim()),
siteKey,
patient?.dateOfBirth || "",
form.serviceDate || serviceDate,
);
if (mismatches.length === 0) {
action();
} else {
pendingClaimAction.current = action;
setPriceMismatches(mismatches);
}
};
const savePricesToSchedule = async (mismatches: PriceMismatch[]) => {
const siteKey = form.insuranceSiteKey || deriveInsuranceSiteKey(form.insuranceProvider || "");
await Promise.all(
mismatches.map(m =>
apiRequest("POST", "/api/fee-schedule/update-price", {
siteKey,
procedureCode: m.procedureCode,
price: m.enteredPrice,
})
)
);
};
const uploadAttachmentsToLocalFolder = async (files: File[]): Promise<ClaimFileMeta[]> => {
if (!files.length) return [];
@@ -1493,13 +1618,6 @@ export function ClaimForm({
))}
</SelectContent>
</Select>
<Button
className="ml-4"
variant="success"
onClick={onMapPrice}
>
Map Price
</Button>
</div>
</div>
@@ -1534,6 +1652,12 @@ export function ClaimForm({
/>
</div>
<div className="flex justify-end mb-2">
<Button variant="success" onClick={onMapPrice}>
Map Price
</Button>
</div>
{/* Header */}
<div className="grid grid-cols-[1.5fr,0.5fr,1fr,1fr,1fr,1fr,1fr,0.5fr,1fr,1fr] gap-1 mb-2 mt-10 font-medium text-sm text-gray-700 items-center">
<div className="grid grid-cols-[auto,1fr] items-center gap-2">
@@ -1867,17 +1991,20 @@ export function ClaimForm({
<div className="flex flex-wrap gap-2 justify-center">
<Button
className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => handleMHSubmit()}
onClick={() => runWithPriceCheck(() => handleMHSubmit())}
>
MH Claim
</Button>
<Button
className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleCCAClaim}
onClick={() => runWithPriceCheck(handleCCAClaim)}
>
CCA Claim
</Button>
<Button className="w-36" variant="outline">
<Button
className="w-36 bg-violet-600 hover:bg-violet-700 text-white"
onClick={() => runWithPriceCheck(handleDDMAClaim)}
>
Delta MA Claim
</Button>
<Button className="w-44" variant="outline">
@@ -1888,7 +2015,7 @@ export function ClaimForm({
</Button>
<Button
className="w-36 bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleClaimSaved}
onClick={() => runWithPriceCheck(handleClaimSaved)}
>
Claim Saved
</Button>
@@ -2053,17 +2180,16 @@ export function ClaimForm({
))}
</SelectContent>
</Select>
<Button
className="ml-4"
variant="success"
onClick={onMapPrice}
>
Map Price
</Button>
</div>
</div>
</div>
<div className="flex justify-end mb-2">
<Button variant="success" onClick={onMapPrice}>
Map Price
</Button>
</div>
{/* Header */}
<div className="grid grid-cols-[1.5fr,0.5fr,1fr,1fr,1fr,1fr,1fr,0.5fr,1fr,1fr] gap-1 mb-2 mt-10 font-medium text-sm text-gray-700 items-center">
<div className="grid grid-cols-[auto,1fr] items-center gap-2">
@@ -2421,6 +2547,47 @@ export function ClaimForm({
</CardContent>
</Tabs>
</Card>
{/* Price mismatch dialog */}
<AlertDialog open={priceMismatches.length > 0} onOpenChange={open => { if (!open) setPriceMismatches([]); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Save new price to the app?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>The following procedure prices differ from the fee schedule:</p>
<ul className="text-sm space-y-1">
{priceMismatches.map(m => (
<li key={m.procedureCode} className="flex justify-between gap-4">
<span className="font-medium">{m.procedureCode}</span>
<span className="text-muted-foreground">Schedule: ${m.schedulePrice.toFixed(2)}</span>
<span className="text-foreground font-semibold">Entered: ${m.enteredPrice.toFixed(2)}</span>
</li>
))}
</ul>
<p className="text-sm">Do you want to save the new price(s) to the fee schedule for future use?</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setPriceMismatches([]);
pendingClaimAction.current?.();
pendingClaimAction.current = null;
}}>
No
</AlertDialogCancel>
<AlertDialogAction onClick={async () => {
await savePricesToSchedule(priceMismatches);
setPriceMismatches([]);
pendingClaimAction.current?.();
pendingClaimAction.current = null;
}}>
Yes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}