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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user