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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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