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
+1
View File
@@ -32,6 +32,7 @@
"node-cron": "^4.2.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"sharp": "^0.35.3",
"socket.io": "^4.8.1",
File diff suppressed because it is too large Load Diff
+50
View File
@@ -19,6 +19,7 @@ import {
} from "@repo/db/types";
import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient";
import { formatDobForAgent } from "../utils/dateUtils";
import { ADA_PAYERS, buildAdaClaimPdf } from "../services/adaClaimForm";
import { backupScreenshots } from "../utils/screenshotBackup";
const router = Router();
@@ -1230,6 +1231,55 @@ router.get("/all", async (req: Request, res: Response) => {
});
// Get a single claim by ID
// GET /api/claims/:id/ada-form?payer=BCBS_MA
// Paper claim: returns the 2024 ADA Dental Claim Form filled from the saved claim.
router.get("/:id/ada-form", async (req: Request, res: Response): Promise<any> => {
try {
if (!req.user?.id) return res.status(401).json({ message: "Unauthorized" });
const claimId = parseInt(String(req.params.id), 10);
if (isNaN(claimId)) return res.status(400).json({ error: "Invalid claim ID" });
const payerKey = String(req.query.payer || "BCBS_MA").toUpperCase();
const payer = ADA_PAYERS[payerKey];
if (!payer) return res.status(400).json({ error: `Unknown payer "${payerKey}"` });
const { prisma: db } = await import("@repo/db/client");
const claim = await db.claim.findUnique({
where: { id: claimId },
include: {
serviceLines: { orderBy: { id: "asc" } },
patient: true,
npiProvider: true,
},
});
if (!claim || claim.userId !== req.user.id) {
return res.status(404).json({ message: "Claim not found" });
}
const office = await storage.getOfficeContact(req.user.id);
// Fall back to the default (first) NPI provider when the claim has none
const treatingProvider =
claim.npiProvider ?? (await storage.getNpiProvidersByUser(req.user.id))[0] ?? null;
const pdf = await buildAdaClaimPdf({
claim,
patient: claim.patient,
office,
treatingProvider,
payer,
});
const filename = `ADA_Claim_${claim.patient.lastName}_${claim.patient.firstName}_${claimId}.pdf`
.replace(/[^\w.-]+/g, "_");
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
return res.send(Buffer.from(pdf));
} catch (error) {
console.error("[ada-form] failed:", error);
return res.status(500).json({ message: "Failed to generate ADA claim form", details: String(error) });
}
});
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
+8 -1
View File
@@ -22,7 +22,10 @@ router.put("/", async (req: Request, res: Response): Promise<any> => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode } = req.body;
const {
officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode,
billingName, billingNpi, billingLicense, billingTaxId,
} = req.body;
const record = await storage.upsertOfficeContact(userId, {
officeName: officeName ?? undefined,
receptionistName: receptionistName ?? undefined,
@@ -34,6 +37,10 @@ router.put("/", async (req: Request, res: Response): Promise<any> => {
city: city ?? undefined,
state: state ?? undefined,
zipCode: zipCode ?? undefined,
billingName: billingName ?? undefined,
billingNpi: billingNpi ?? undefined,
billingLicense: billingLicense ?? undefined,
billingTaxId: billingTaxId ?? undefined,
});
return res.status(200).json(record);
} catch (err) {
+321
View File
@@ -0,0 +1,321 @@
import fs from "fs";
import path from "path";
import { PDFDocument, PDFFont, PDFForm, StandardFonts } from "pdf-lib";
/**
* Fills the 2024 ADA Dental Claim Form (J43024) for payers that only accept
* paper claims (e.g. BCBS MA). Field names come from the template's AcroForm;
* signatures and their dates are intentionally left blank for wet signing.
*/
const TEMPLATE_PATH = path.join(__dirname, "../data/templates/ada-claim-form-2024.pdf");
const PROCEDURE_CODES_PATH = path.join(__dirname, "../data/procedureCodes.json");
const LINES_PER_FORM = 10;
const FONT_SIZE = 10;
const MIN_FONT_SIZE = 6;
export type AdaPayer = {
name: string;
address1: string;
address2?: string;
cityStateZip: string;
};
export const ADA_PAYERS: Record<string, AdaPayer> = {
BCBS_MA: {
name: "Blue Cross Blue Shield of MA",
address1: "P.O. Box 986005",
cityStateZip: "Boston, MA 02298",
},
};
export type AdaClaimInput = {
claim: {
id: number;
memberId?: string | null;
remarks?: string | null;
status?: string | null;
preAuthNumber?: string | null;
missingTeeth?: unknown;
serviceLines: {
procedureCode: string;
procedureDate: Date | string;
quad?: string | null;
arch?: string | null;
toothNumber?: string | null;
toothSurface?: string | null;
totalBilled: unknown;
}[];
};
patient: {
id: number;
firstName: string;
lastName: string;
dateOfBirth?: Date | string | null;
gender?: string | null;
address?: string | null;
city?: string | null;
zipCode?: string | null;
insuranceId?: string | null;
groupNumber?: string | null;
policyHolder?: string | null;
};
office: {
officeName?: string | null;
phoneNumber?: string | null;
streetAddress?: string | null;
city?: string | null;
state?: string | null;
zipCode?: string | null;
billingName?: string | null;
billingNpi?: string | null;
billingLicense?: string | null;
billingTaxId?: string | null;
} | null;
treatingProvider: {
npiNumber: string;
licenseNumber?: string | null;
} | null;
payer: AdaPayer;
};
let descriptionByCode: Map<string, string> | null = null;
function cdtDescription(code: string): string {
if (!descriptionByCode) {
descriptionByCode = new Map();
try {
const rows = JSON.parse(fs.readFileSync(PROCEDURE_CODES_PATH, "utf8")) as {
"Procedure Code": string;
Description: string;
}[];
for (const r of rows) descriptionByCode.set(r["Procedure Code"].toUpperCase(), r.Description);
} catch {
// descriptions are optional on the form
}
}
return descriptionByCode.get(code.toUpperCase()) ?? "";
}
function fmtDate(d: Date | string | null | undefined): string {
if (!d) return "";
const date = d instanceof Date ? d : new Date(d);
if (isNaN(date.getTime())) return "";
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
return `${mm}/${dd}/${date.getUTCFullYear()}`;
}
function fmtMoney(v: unknown): string {
const n = Number(v ?? 0);
return isNaN(n) ? "" : n.toFixed(2);
}
function cityStateZip(city?: string | null, state?: string | null, zip?: string | null): string {
const left = [city?.trim(), state?.trim()].filter(Boolean).join(", ");
return [left, zip?.trim()].filter(Boolean).join(" ");
}
/** Item 25: ADA area-of-oral-cavity codes. */
function oralCavityArea(quad?: string | null, arch?: string | null): string {
switch ((quad ?? "").toUpperCase()) {
case "UR": return "10";
case "UL": return "20";
case "LL": return "30";
case "LR": return "40";
}
switch ((arch ?? "").toLowerCase()) {
case "maxilla": return "01";
case "mandible": return "02";
}
return "";
}
/** "M"/"F"/"U" from free-text gender. */
function genderCode(g?: string | null): "M" | "F" | "U" {
const s = (g ?? "").trim().toLowerCase();
if (s.startsWith("m")) return "M";
if (s.startsWith("f")) return "F";
return "U";
}
function splitPhone(phone?: string | null): [string, string, string] | null {
const digits = (phone ?? "").replace(/\D/g, "").replace(/^1(?=\d{10}$)/, "");
if (digits.length !== 10) return null;
return [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6)];
}
function normName(s: string): string {
return s.toLowerCase().replace(/[^a-z]/g, " ").split(/\s+/).filter(Boolean).sort().join(" ");
}
function setText(form: PDFForm, name: string, value: string | null | undefined) {
if (!value) return;
const field = form.getTextField(name);
field.setFontSize(FONT_SIZE);
field.setText(value);
}
/** Left-aligned text shrunk (then truncated) to fit the field width. */
function setFittedText(form: PDFForm, font: PDFFont, name: string, value: string) {
if (!value) return;
const field = form.getTextField(name);
const width = field.acroField.getWidgets()[0]!.getRectangle().width - 4;
let size = FONT_SIZE;
while (size > MIN_FONT_SIZE && font.widthOfTextAtSize(value, size) > width) size -= 0.5;
let text = value;
while (text.length > 1 && font.widthOfTextAtSize(text, size) > width) text = text.slice(0, -1);
field.setAlignment(0);
field.setFontSize(size);
field.setText(text);
}
function check(form: PDFForm, name: string) {
form.getCheckBox(name).check();
}
/** Field-name suffix for service line i (0-based): "1".."9", then "Row10" (Fee uses "10"). */
function lineSuffix(i: number, field: "fee" | "other"): string {
if (i < 9) return String(i + 1);
return field === "fee" ? "10" : "Row10";
}
async function fillOnePage(
template: Uint8Array,
input: AdaClaimInput,
lines: AdaClaimInput["claim"]["serviceLines"],
): Promise<PDFDocument> {
const doc = await PDFDocument.load(template);
// Keep page 1 only (pages 2-3 are instructions / fraud warnings)
while (doc.getPageCount() > 1) doc.removePage(doc.getPageCount() - 1);
const form = doc.getForm();
const helvetica = await doc.embedFont(StandardFonts.Helvetica);
const { claim, patient, office, treatingProvider, payer } = input;
const officeState = office?.state ?? "";
// 1-2. Header
if (claim.status === "PREAUTH") {
check(form, "1 Check Box 1");
setText(form, "2 PredeterminationPreauthorization Number", claim.preAuthNumber);
} else {
check(form, "1 Check Box 2");
}
// 3. Payer
setText(form, "3 CompanyPlan Name", payer.name);
setText(form, "3 Address 1", payer.address1);
setText(form, "3 Address 2", payer.address2);
setText(form, "3 City, State, Zip Code", payer.cityStateZip);
// 12-23. Subscriber / patient
const patientName = `${patient.lastName}, ${patient.firstName}`;
const patientCsz = cityStateZip(patient.city, patient.address || patient.city ? officeState : "", patient.zipCode);
const dob = fmtDate(patient.dateOfBirth);
const gender = genderCode(patient.gender);
const genderBox = { M: "Check Box1", F: "Check Box2", U: "Check Box3" }[gender];
const holder = (patient.policyHolder ?? "").trim();
const isSelf =
!holder ||
holder.toLowerCase() === "self" ||
normName(holder) === normName(`${patient.firstName} ${patient.lastName}`);
if (isSelf) {
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", patientName);
setText(form, "12 Address 1", patient.address);
setText(form, "12 City, State, Zip", patientCsz);
setText(form, "13 Date of Birth MMDDCCYY", dob);
check(form, `14 ${genderBox}`);
check(form, "18 Check Box1");
} else {
// Subscriber is someone else: name only; DOB, gender and relationship are completed by hand.
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", holder);
}
setText(form, "15 Policyho derSubscriber ID Assigned by Plan", claim.memberId || patient.insuranceId);
setText(form, "16 PlanGroup Number", patient.groupNumber);
setText(form, "20 Name Last First Middle Initial Suffix", patientName);
setText(form, "20 Address 1", patient.address);
setText(form, "20 City, State, Zip", patientCsz);
setText(form, "21 Date of Birth MMDDCCYY", dob);
check(form, `22 ${genderBox}`);
setText(form, "23 Patient IDAccount Assigned by Dentist", String(patient.id));
// 24-31. Service lines
let total = 0;
lines.forEach((line, i) => {
const s = lineSuffix(i, "other");
const tooth = (line.toothNumber ?? "").trim();
setText(form, `24 Procedure Date MMDDCCYY${s}`, fmtDate(line.procedureDate));
setText(form, `25 Area of Oral Cavity${s}`, oralCavityArea(line.quad, line.arch));
setText(form, `26 Tooth System${s}`, tooth ? "JP" : "");
setText(form, `27 Tooth Numbers or Letters${s}`, tooth);
setText(form, `28 Tooth Surface${s}`, (line.toothSurface ?? "").trim());
setText(form, `29 Procedure Code${s}`, line.procedureCode.trim().toUpperCase());
setFittedText(form, helvetica, `30 Description${s}`, cdtDescription(line.procedureCode.trim()));
setText(form, `31 Fee${lineSuffix(i, "fee")}`, fmtMoney(line.totalBilled));
total += Number(line.totalBilled ?? 0) || 0;
});
setText(form, "32 Total Fee", total.toFixed(2));
// 33. Missing teeth — { "T_14": "X", ... }; only permanent teeth 1-32 have boxes
if (claim.missingTeeth && typeof claim.missingTeeth === "object") {
for (const [key, val] of Object.entries(claim.missingTeeth as Record<string, unknown>)) {
const n = Number(key.replace(/^T_/, ""));
if (val === "X" && Number.isInteger(n) && n >= 1 && n <= 32) check(form, `33 box${n}`);
}
}
// 35. Remarks
setText(form, "35 Remarks", claim.remarks?.trim());
// 38-40. Ancillary
setText(form, "38 Place of Treatment", "11");
check(form, "40 Check Box1");
// 48-52. Billing dentist (office NPI)
const officeCsz = cityStateZip(office?.city, office?.state, office?.zipCode);
const officeLines = [office?.billingName, office?.officeName, office?.streetAddress]
.map((v) => v?.trim())
.filter(Boolean) as string[];
setText(form, "48 Name", officeLines[0]);
setText(form, "48 Address", officeLines[1]);
setText(form, "48 Address 2", officeLines[2]);
setText(form, "48 City, State, Zip", officeCsz);
setText(form, "49 NPI", office?.billingNpi);
setText(form, "50 License Number", office?.billingLicense);
setText(form, "51 SSN or TIN", office?.billingTaxId);
const phone = splitPhone(office?.phoneNumber);
if (phone) {
for (const item of ["52", "57"]) {
setText(form, `${item} area code`, phone[0]);
setText(form, `${item} phone 1`, phone[1]);
setText(form, `${item} phone 2`, phone[2]);
}
}
// 54-56. Treating dentist and location
setText(form, "54 NPI", treatingProvider?.npiNumber);
setText(form, "55 License Number", treatingProvider?.licenseNumber || office?.billingLicense);
setText(form, "56 Address 1", office?.streetAddress);
setText(form, "56 Address 2", officeCsz);
form.updateFieldAppearances(helvetica);
form.flatten();
return doc;
}
/** Returns the filled form; claims with more than 10 lines continue on additional complete forms. */
export async function buildAdaClaimPdf(input: AdaClaimInput): Promise<Uint8Array> {
const template = fs.readFileSync(TEMPLATE_PATH);
const lines = input.claim.serviceLines;
const out = await PDFDocument.create();
for (let i = 0; i < Math.max(lines.length, 1); i += LINES_PER_FORM) {
const filled = await fillOnePage(template, input, lines.slice(i, i + LINES_PER_FORM));
const [page] = await out.copyPages(filled, [0]);
out.addPage(page!);
}
out.setTitle(`ADA Claim Form - ${input.patient.lastName}, ${input.patient.firstName}`);
return out.save();
}
@@ -16,6 +16,10 @@ export const officeContactStorage = {
city?: string;
state?: string;
zipCode?: string;
billingName?: string;
billingNpi?: string;
billingLicense?: string;
billingTaxId?: string;
}) {
return db.officeContact.upsert({
where: { userId },
@@ -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;
}
}