feat: BCBS MA paper claim — fill 2024 ADA claim form; office NPI setting

- New GET /api/claims/:id/ada-form fills the 2024 ADA Dental Claim Form
  (J43024) with payer address, patient/subscriber, CDT lines, fees,
  missing teeth, billing (office) NPI and treating NPI; signatures blank
- "BCBS Claim (ADA Form)" button saves the claim and opens the PDF;
  Claim All routes BCBS patients to it; "Print ADA Form" in claim view
- Settings > NPI Providers: "Add Office NPI" (billing name, NPI, license,
  TIN) and optional per-provider license number
- Migration: office_contact billing fields, NpiProvider.licenseNumber

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 22:41:44 -04:00
co-authored by Claude Opus 5.5
parent d67e68c957
commit 2c0c595af5
294 changed files with 13054 additions and 1776 deletions
@@ -66,6 +66,7 @@ import {
} from "@/components/procedure/procedure-combo-buttons";
import { inferTypeFromProcedureCodes, getAppointmentTypeLabel } from "@/utils/appointmentTypeUtils";
import { Switch } from "@/components/ui/switch";
import { openPendingTab, showAdaClaimForm } from "@/utils/adaClaimForm";
import {
AlertDialog,
AlertDialogAction,
@@ -1562,6 +1563,8 @@ export function ClaimForm({
runWithPriceCheck(handleUnitedDHClaim);
} else if (siteKey === "UNITEDUCARD" || siteKey === "UNITED_UCARD") {
runWithPriceCheck(handleUnitedUcardClaim);
} else if (siteKey === "BCBS" || siteKey === "BCBS_MA") {
runWithPriceCheck(handleBCBSClaim);
} else {
toast({
title: "No automated claim for this insurance",
@@ -1888,6 +1891,76 @@ export function ClaimForm({
// Saves claim to DB with all info (member ID, DOB, service date, provider, CDT codes, NPI).
// No Selenium action — works like a real MassHealth claim for payment/report tracking.
// BCBS MA has no online claim submission: save the claim, then open the filled
// 2024 ADA paper form to print, sign and mail.
const handleBCBSClaim = 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: ${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 creating the claim form.",
variant: "destructive",
});
return;
}
// Open the tab before any await so the browser doesn't block it
const tab = openPendingTab();
const appointmentIdToUse = appointmentId ?? null;
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
try {
const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments(
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: patientId,
insuranceProvider: "BCBS MA",
appointmentId: appointmentIdToUse,
claimFiles: claimFilesMeta,
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
});
await showAdaClaimForm(createdClaim.id!, tab);
toast({
title: "BCBS claim form ready",
description: "Claim saved. Print the ADA form, sign it, and mail it to BCBS MA.",
});
onClose();
} catch (err: any) {
tab?.close();
toast({
title: "BCBS claim failed",
description: err?.message ?? "Failed to create the ADA claim form.",
variant: "destructive",
});
}
};
const handleClaimSaved = async () => {
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
@@ -2698,6 +2771,13 @@ export function ClaimForm({
>
United Ucard Claim
</Button>
<Button
className="w-44 bg-sky-700 hover:bg-sky-800 text-white"
onClick={() => runWithPriceCheck(handleBCBSClaim)}
title="Save the claim and open a filled ADA paper form to print and mail"
>
BCBS Claim (ADA Form)
</Button>
<Button
className="w-36 bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => runWithPriceCheck(handleClaimSaved)}
@@ -9,7 +9,9 @@ import { Button } from "@/components/ui/button";
import React from "react";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { ClaimFileMeta, ClaimWithServiceLines } from "@repo/db/types";
import { FileText, Paperclip } from "lucide-react";
import { FileText, Paperclip, Printer } from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { openPendingTab, showAdaClaimForm } from "@/utils/adaClaimForm";
import {
safeParseMissingTeeth,
splitTeeth,
@@ -352,6 +354,23 @@ export default function ClaimViewModal({
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
{/bcbs|blue cross/i.test(claim.insuranceProvider ?? "") && (
<Button
variant="outline"
onClick={() => {
const tab = openPendingTab();
showAdaClaimForm(claim.id!, tab).catch((err) =>
toast({
title: "ADA form failed",
description: err?.message ?? "Failed to generate ADA claim form.",
variant: "destructive",
}),
);
}}
>
<Printer className="mr-2 h-4 w-4" /> Print ADA Form
</Button>
)}
<Button
onClick={() => {
onOpenChange(false);
@@ -9,6 +9,7 @@ type Props = {
id?: number;
npiNumber: string;
providerName: string;
licenseNumber?: string | null;
};
};
@@ -20,6 +21,10 @@ export function NpiProviderForm({ onClose, defaultValues }: Props) {
defaultValues?.providerName || ""
);
const [licenseNumber, setLicenseNumber] = useState(
defaultValues?.licenseNumber || ""
);
const queryClient = useQueryClient();
const mutation = useMutation({
@@ -27,6 +32,7 @@ export function NpiProviderForm({ onClose, defaultValues }: Props) {
const payload = {
npiNumber: npiNumber.trim(),
providerName: providerName.trim(),
licenseNumber: licenseNumber.trim() || null,
};
const url = defaultValues?.id
@@ -67,6 +73,7 @@ export function NpiProviderForm({ onClose, defaultValues }: Props) {
useEffect(() => {
setNpiNumber(defaultValues?.npiNumber || "");
setProviderName(defaultValues?.providerName || "");
setLicenseNumber(defaultValues?.licenseNumber || "");
}, [defaultValues]);
const handleSubmit = (e: React.FormEvent) => {
@@ -120,6 +127,19 @@ export function NpiProviderForm({ onClose, defaultValues }: Props) {
/>
</div>
<div>
<label className="block text-sm font-medium">
License Number <span className="text-gray-400">(optional)</span>
</label>
<input
type="text"
value={licenseNumber}
onChange={(e) => setLicenseNumber(e.target.value)}
className="mt-1 p-2 border rounded w-full"
placeholder="e.g., DN1855772 — defaults to the office license"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
@@ -5,11 +5,13 @@ import { Button } from "../ui/button";
import { Edit, Delete, Plus, ChevronUp, ChevronDown } from "lucide-react";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import { NpiProviderForm } from "./npiProviderForm";
import { OfficeNpiForm, OfficeNpi } from "./officeNpiForm";
type NpiProvider = {
id: number;
npiNumber: string;
providerName: string;
licenseNumber?: string | null;
sortOrder: number;
};
@@ -21,6 +23,7 @@ export function NpiProviderTable() {
const [editingProvider, setEditingProvider] = useState<NpiProvider | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [providerToDelete, setProviderToDelete] = useState<NpiProvider | null>(null);
const [officeNpiOpen, setOfficeNpiOpen] = useState(false);
const providersPerPage = 5;
@@ -33,6 +36,16 @@ export function NpiProviderTable() {
},
});
const { data: officeNpi } = useQuery<OfficeNpi | null>({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok) return null;
return res.json();
},
});
const hasOfficeNpi = !!officeNpi?.billingNpi;
const reorderMutation = useMutation({
mutationFn: async (orderedIds: number[]) => {
const res = await apiRequest("POST", "/api/npiProviders/reorder", { orderedIds });
@@ -86,16 +99,35 @@ export function NpiProviderTable() {
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">NPI Providers</h2>
<Button
onClick={() => {
setEditingProvider(null);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" /> Add NPI Providers
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setOfficeNpiOpen(true)}>
{hasOfficeNpi ? (
<><Edit className="mr-2 h-4 w-4" /> Edit Office NPI</>
) : (
<><Plus className="mr-2 h-4 w-4" /> Add Office NPI</>
)}
</Button>
<Button
onClick={() => {
setEditingProvider(null);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" /> Add NPI Providers
</Button>
</div>
</div>
{hasOfficeNpi && (
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50 text-sm flex flex-wrap gap-x-6 gap-y-1">
<span className="font-medium text-gray-700">Office NPI (billing)</span>
<span>{officeNpi?.billingName}</span>
<span>NPI: {officeNpi?.billingNpi}</span>
{officeNpi?.billingLicense && <span>License: {officeNpi.billingLicense}</span>}
{officeNpi?.billingTaxId && <span>TIN: {officeNpi.billingTaxId}</span>}
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
@@ -109,25 +141,28 @@ export function NpiProviderTable() {
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Provider Name
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
License #
</th>
<th className="px-4 py-2 w-32" />
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (
<tr>
<td colSpan={4} className="text-center py-4">
<td colSpan={5} className="text-center py-4">
Loading NPI providers...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={4} className="text-center py-4 text-red-600">
<td colSpan={5} className="text-center py-4 text-red-600">
Error loading NPI providers
</td>
</tr>
) : currentProviders.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-4">
<td colSpan={5} className="text-center py-4">
No NPI providers found.
</td>
</tr>
@@ -153,6 +188,7 @@ export function NpiProviderTable() {
</td>
<td className="px-4 py-2 text-sm">{provider.npiNumber}</td>
<td className="px-4 py-2 text-sm">{provider.providerName}</td>
<td className="px-4 py-2 text-sm">{provider.licenseNumber}</td>
<td className="px-4 py-2 text-right">
<Button
variant="ghost"
@@ -224,6 +260,10 @@ export function NpiProviderTable() {
/>
)}
{officeNpiOpen && (
<OfficeNpiForm defaultValues={officeNpi} onClose={() => setOfficeNpiOpen(false)} />
)}
<DeleteConfirmationDialog
isOpen={isDeleteDialogOpen}
onConfirm={handleConfirmDelete}
@@ -0,0 +1,116 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
export type OfficeNpi = {
billingName?: string | null;
billingNpi?: string | null;
billingLicense?: string | null;
billingTaxId?: string | null;
};
type Props = {
onClose: () => void;
defaultValues?: OfficeNpi | null;
};
// Office (billing) NPI — printed in items 48-51 of the ADA paper claim form.
// Stored on the office contact record; address and phone come from Office Contact.
export function OfficeNpiForm({ onClose, defaultValues }: Props) {
const [billingName, setBillingName] = useState(defaultValues?.billingName || "");
const [billingNpi, setBillingNpi] = useState(defaultValues?.billingNpi || "");
const [billingLicense, setBillingLicense] = useState(defaultValues?.billingLicense || "");
const [billingTaxId, setBillingTaxId] = useState(defaultValues?.billingTaxId || "");
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("PUT", "/api/office-contact", {
billingName: billingName.trim(),
billingNpi: billingNpi.trim(),
billingLicense: billingLicense.trim(),
billingTaxId: billingTaxId.trim(),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save office NPI");
}
return res.json();
},
onSuccess: () => {
toast({ title: "Office NPI saved." });
queryClient.invalidateQueries({ queryKey: ["/api/office-contact"] });
onClose();
},
onError: (error: any) => {
toast({ title: "Error", description: error.message, variant: "destructive" });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!billingName.trim() || !billingNpi.trim()) {
toast({
title: "Error",
description: "Billing name and office NPI are required.",
variant: "destructive",
});
return;
}
mutation.mutate();
};
const fields: {
label: string;
value: string;
set: (v: string) => void;
placeholder: string;
}[] = [
{ label: "Billing Entity Name", value: billingName, set: setBillingName, placeholder: "e.g., KAI GAO DMD LLC" },
{ label: "Office NPI (Type 2)", value: billingNpi, set: setBillingNpi, placeholder: "e.g., 1497211726" },
{ label: "License Number", value: billingLicense, set: setBillingLicense, placeholder: "e.g., DN1855772" },
{ label: "Tax ID (TIN)", value: billingTaxId, set: setBillingTaxId, placeholder: "e.g., 462938755" },
];
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
<h2 className="text-lg font-bold mb-1">Office NPI</h2>
<p className="text-sm text-gray-500 mb-4">
Printed as the billing dentist (items 48–51) on paper ADA claim forms. Address and
phone come from Office Contact.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((f) => (
<div key={f.label}>
<label className="block text-sm font-medium">{f.label}</label>
<input
type="text"
value={f.value}
onChange={(e) => f.set(e.target.value)}
className="mt-1 p-2 border rounded w-full"
placeholder={f.placeholder}
/>
</div>
))}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="text-gray-600 hover:underline">
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{mutation.isPending ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { apiRequest } from "@/lib/queryClient";
/**
* Opens the filled 2024 ADA paper claim form for a saved claim in a new tab.
* Call synchronously from a click handler (before any await) so the tab
* isn't popup-blocked: `const tab = openPendingTab()` then `showAdaClaimForm(id, tab)`.
*/
export function openPendingTab(): Window | null {
const tab = window.open("", "_blank");
if (tab) tab.document.title = "Preparing ADA claim form…";
return tab;
}
export async function showAdaClaimForm(
claimId: number,
tab: Window | null = null,
payer = "BCBS_MA",
): Promise<void> {
try {
const res = await apiRequest("GET", `/api/claims/${claimId}/ada-form?payer=${payer}`);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to generate ADA claim form");
}
const url = URL.createObjectURL(await res.blob());
if (tab && !tab.closed) {
tab.location.href = url;
} else {
// Tab was blocked: fall back to a download, which browsers don't block
const a = document.createElement("a");
a.href = url;
a.download = `ADA_Claim_${claimId}.pdf`;
a.click();
}
// Leave time for the tab to load before releasing the blob
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
tab?.close();
throw err;
}
}