feat: Preauth column for AI claims; searchable policy holder + ADA box 18; 2PA fix

- Schedule: last column is the Preauth column; "Claim for Column with AI" is now
  "Claim/Preauth for Column with AI" — that column's appointments go to PreAuth
  (MH, CCA, Tufts SCO, United/DentalHub; other insurances are skipped), the rest
  to Claim. Preauthorized appointments (hasPreauth) aren't resubmitted
- PreAuth payloads carry appointmentId; appointment X-rays/attachments are sent
  with MH/CCA/UnitedDH/Tufts preauths (shared appendAppointmentFiles helper)
- Patient form: policy holder is searchable from patients in the DB, plus
  "Relationship to Policy Holder/Subscriber" (Self, Spouse, Dependent Child,
  Other). New Patient columns policyHolderPatientId, policyHolderRelationship;
  browser-side schema updated so the fields aren't stripped on save
- ADA form: box 12-14 filled from the linked policy holder's record; box 18
  ticks the relationship (Self when the patient is the holder)
- AI notes: a bare "2PA" is an image count, not tooth #2 — restored before CDT
  lookup so it maps to D0220 #8 / D0230 #24

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 21:09:51 -04:00
co-authored by Claude Opus 5.5
parent c3efdc7f8e
commit 44a1a41efa
261 changed files with 2159 additions and 195 deletions
@@ -206,6 +206,8 @@ Rules:
e.g. "3 PA (#3, 14, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #30"]
e.g. "4 PA (#3, 14, 19, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #19", "2nd pa, #30"]
e.g. "2 pa #3 #14" → ["1 pa, #3", "2nd pa, #14"]
- A number written right before PA with NO tooth numbers ("2PA", "4 PA") is the image count, never a tooth number — keep it exactly as written:
e.g. "perio exam, 2PA, 4BW" → ["perio exam", "2PA", "4BW"] (NOT "1 pa, #2")
- insuranceHint is only set when the user explicitly names an insurance in the message
- renderingProvider is only set when the user explicitly names a treating/rendering provider or doctor
e.g. "with provider Kai Gao", "provider Dr. Smith", "rendered by Kai Gao", "doctor Kai Gao"
@@ -239,6 +241,19 @@ Rules:
// nothing else in it — e.g. "100232102531 4/27/1999" or "100232102531, 4/27/1999".
// Bypasses the LLM entirely so this always resolves to eligibility_by_id, since some
// providers hedge/refuse structured output on a bare digit string that looks like an SSN.
// "2PA" / "4 PA" with no tooth numbers is an image count. The LLM sometimes rewrites it as
// "1 pa, #2" (count read as a tooth), so put the original "NPA" back for the CDT lookup.
function restoreBarePaCount(message: string, names: string[]): string[] {
const bare = [...message.matchAll(/\b(\d+)\s*pas?\b(?!\s*[(#])/gi)];
if (bare.length !== 1) return names;
const isPa = (n: string) => /^\s*(?:\d+(?:st|nd|rd|th)?\s*)?(?:pa|periapical)\b/i.test(n);
const first = names.findIndex(isPa);
if (first === -1) return names;
const kept = names.filter((n) => !isPa(n));
kept.splice(first, 0, `${bare[0]![1]}PA`);
return kept;
}
function parseBareIdDob(message: string): { memberId: string; dob: string } | null {
const text = message.trim();
const dobPattern = /(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{4})/;
@@ -331,6 +346,7 @@ export async function classifyInternalChat(
const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
const parsed = JSON.parse(jsonStr) as ChatClassification;
if (!parsed.intent || !parsed.fallbackReply) return fallback;
if (parsed.procedureNames) parsed.procedureNames = restoreBarePaCount(message, parsed.procedureNames);
return parsed;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
+3 -1
View File
@@ -102,16 +102,18 @@ router.get("/day", async (req: Request, res: Response): Promise<any> => {
// Enrich each appointment with procedure / claim status flags
const appointmentIds = appointments.map((a) => a.id).filter((id): id is number => id != null);
const [idsWithProcedures, idsWithClaimNumbers, procedureCodesByAppt] = await Promise.all([
const [idsWithProcedures, idsWithClaimNumbers, procedureCodesByAppt, idsWithPreauth] = await Promise.all([
storage.getAppointmentIdsWithProcedures(appointmentIds),
storage.getAppointmentIdsWithClaimNumbers(appointmentIds),
storage.getProcedureCodesByAppointmentIds(appointmentIds),
storage.getAppointmentIdsWithPreauth(appointmentIds),
]);
const enrichedAppointments = appointments.map((a) => ({
...a,
hasProcedures: a.id != null && idsWithProcedures.has(a.id),
hasClaimWithNumber: a.id != null && idsWithClaimNumbers.has(a.id),
hasPreauth: a.id != null && idsWithPreauth.has(a.id),
procedureCodes: a.id != null ? (procedureCodesByAppt.get(a.id) ?? []) : [],
}));
+12 -16
View File
@@ -21,6 +21,7 @@ import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranc
import { formatDobForAgent } from "../utils/dateUtils";
import { ADA_PAYERS, buildAdaClaimPdf } from "../services/adaClaimForm";
import { backupScreenshots } from "../utils/screenshotBackup";
import { appendAppointmentFiles } from "../utils/appointmentAttachments";
const router = Router();
@@ -347,24 +348,11 @@ router.post(
mimetype: f.mimetype,
}));
// Include attachments already saved on the appointment (e.g. via "Select Procedures")
// that weren't re-uploaded in this request — read straight from disk by filePath.
// Include attachments already saved on the appointment.
// Skipped for chatbot-driven submissions, which already send every intended file and
// shouldn't have unrelated older appointment attachments silently tacked on.
if (claimData.appointmentId && !claimData.skipAppointmentAttachmentMerge) {
const seenNames = new Set(filesForQueue.map((f) => f.originalname));
const apptFiles = await storage.getAppointmentFiles(Number(claimData.appointmentId));
for (const f of apptFiles) {
if (!f.filePath || seenNames.has(f.filename)) continue;
const absPath = path.join(process.cwd(), f.filePath);
if (!fs.existsSync(absPath)) continue;
filesForQueue.push({
originalname: f.filename,
bufferBase64: fs.readFileSync(absPath).toString("base64"),
mimetype: f.mimeType ?? "application/octet-stream",
});
seenNames.add(f.filename);
}
if (!claimData.skipAppointmentAttachmentMerge) {
await appendAppointmentFiles(filesForQueue, claimData.appointmentId);
}
const job = await seleniumQueue.add("claim-submit", {
@@ -564,6 +552,9 @@ router.post(
mimetype: f.mimetype,
}));
// Include X-rays etc. already attached to the appointment
await appendAppointmentFiles(filesForQueue, claimData.appointmentId);
const job = await seleniumQueue.add("claim-pre-auth", {
jobType: "claim-pre-auth",
userId: req.user.id,
@@ -1263,9 +1254,14 @@ async function buildAdaFormForRequest(req: Request): Promise<AdaFormResult> {
const treatingProvider =
claim.npiProvider ?? (await storage.getNpiProvidersByUser(req.user!.id))[0] ?? null;
const holderId = (claim.patient as any).policyHolderPatientId as number | null | undefined;
const policyHolderPatient =
holderId && holderId !== claim.patient.id ? ((await storage.getPatient(holderId)) ?? null) : null;
const pdf = await buildAdaClaimPdf({
claim,
patient: claim.patient,
policyHolderPatient,
office,
treatingProvider,
payer,
@@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
import { storage } from "../storage";
import { enqueueSeleniumJob } from "../queue/jobRunner";
import multer from "multer";
import { appendAppointmentFiles } from "../utils/appointmentAttachments";
const router = Router();
const upload = multer({ storage: multer.memoryStorage() });
@@ -55,6 +56,8 @@ router.post(
bufferBase64: f.buffer.toString("base64"),
mimetype: f.mimetype,
}));
// Include X-rays etc. already attached to the appointment
await appendAppointmentFiles(filesForQueue, claimData.appointmentId);
const enrichedPayload = {
claim: {
+37 -6
View File
@@ -62,7 +62,19 @@ export type AdaClaimInput = {
insuranceId?: string | null;
groupNumber?: string | null;
policyHolder?: string | null;
policyHolderRelationship?: string | null;
};
/** Policy holder's own patient record (Patient.policyHolderPatientId), when not self */
policyHolderPatient?: {
firstName: string;
lastName: string;
dateOfBirth?: Date | string | null;
gender?: string | null;
address?: string | null;
city?: string | null;
state?: string | null;
zipCode?: string | null;
} | null;
office: {
legalName?: string | null;
officeName?: string | null;
@@ -257,10 +269,12 @@ async function fillOnePage(
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}`);
const relationship = (patient.policyHolderRelationship ?? "").trim().toUpperCase();
const isSelf = relationship
? relationship === "SELF"
: !holder ||
holder.toLowerCase() === "self" ||
normName(holder) === normName(`${patient.firstName} ${patient.lastName}`);
if (isSelf) {
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", patientName);
@@ -268,11 +282,28 @@ async function fillOnePage(
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 if (input.policyHolderPatient) {
// Subscriber is another patient in the system: fill 12-14 from their record
const h = input.policyHolderPatient;
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", `${h.lastName}, ${h.firstName}`);
setText(form, "12 Address 1", h.address);
setText(
form,
"12 City, State, Zip",
cityStateZip(h.city, h.state || (h.address || h.city ? officeState : ""), h.zipCode),
);
setText(form, "13 Date of Birth MMDDCCYY", fmtDate(h.dateOfBirth));
const hGenderBox = { M: "Check Box1", F: "Check Box2", U: "Check Box3" }[genderCode(h.gender)];
check(form, `14 ${hGenderBox}`);
} else {
// Subscriber is someone else: name only; DOB, gender and relationship are completed by hand.
// Subscriber typed by name only: DOB, gender and address are completed by hand.
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", holder);
}
// 18. Relationship to policyholder: Self, Spouse, Dependent Child, Other (left to right)
const relationshipBox = isSelf
? "Check Box1"
: { SPOUSE: "Check Box2", DEPENDENT: "Check Box3", OTHER: "Check Box4" }[relationship];
if (relationshipBox) check(form, `18 ${relationshipBox}`);
setText(form, "15 Policyho derSubscriber ID Assigned by Plan", claim.memberId || patient.insuranceId);
setText(form, "16 PlanGroup Number", patient.groupNumber);
@@ -24,6 +24,7 @@ export interface IStorage {
updateClaimStatus(id: number, status: ClaimStatus): Promise<Claim>;
deleteClaim(id: number): Promise<void>;
getAppointmentIdsWithClaimNumbers(ids: number[]): Promise<Set<number>>;
getAppointmentIdsWithPreauth(ids: number[]): Promise<Set<number>>;
}
export const claimsStorage: IStorage = {
@@ -137,4 +138,19 @@ export const claimsStorage: IStorage = {
});
return new Set(rows.map((r: { appointmentId: number | null }) => r.appointmentId));
},
async getAppointmentIdsWithPreauth(ids: number[]): Promise<Set<number>> {
if (!ids.length) return new Set();
const rows = await db.claim.findMany({
// PREAUTH records are created before the Selenium run, so only count ones that got a number back
where: {
appointmentId: { in: ids },
status: "PREAUTH" as ClaimStatus,
OR: [{ preAuthNumber: { not: null } }, { claimNumber: { not: null } }],
},
select: { appointmentId: true },
distinct: ["appointmentId"],
});
return new Set(rows.map((r: { appointmentId: number | null }) => r.appointmentId));
},
};
@@ -0,0 +1,27 @@
import path from "path";
import fs from "fs";
import { storage } from "../storage";
type QueuedFile = { originalname: string; bufferBase64: string; mimetype: string };
// Include attachments already saved on the appointment (e.g. X-rays added via "Select Procedures")
// that weren't re-uploaded in this request — read straight from disk by filePath.
export async function appendAppointmentFiles(
filesForQueue: QueuedFile[],
appointmentId: number | string | null | undefined,
): Promise<void> {
if (!appointmentId) return;
const seenNames = new Set(filesForQueue.map((f) => f.originalname));
const apptFiles = await storage.getAppointmentFiles(Number(appointmentId));
for (const f of apptFiles) {
if (!f.filePath || seenNames.has(f.filename)) continue;
const absPath = path.join(process.cwd(), f.filePath);
if (!fs.existsSync(absPath)) continue;
filesForQueue.push({
originalname: f.filename,
bufferBase64: fs.readFileSync(absPath).toString("base64"),
mimetype: f.mimeType ?? "application/octet-stream",
});
seenNames.add(f.filename);
}
}
@@ -638,7 +638,8 @@ export function ClaimForm({
insuranceSiteKey,
});
});
setPrefillDone(true);
// With an appointment, 2b (procedures + X-ray attachments) marks prefill done
if (!appointmentId) setPrefillDone(true);
} catch {}
}, []);
@@ -1118,6 +1119,7 @@ export function ClaimForm({
patientId: patientId,
insuranceProvider: "Mass Health",
insuranceSiteKey: "MH",
appointmentId: appointmentId ?? 0,
});
// 5. Close form
@@ -1607,6 +1609,7 @@ export function ClaimForm({
patientId,
insuranceProvider: "CCA",
insuranceSiteKey: "CCA",
appointmentId: appointmentId ?? 0,
});
onClose();
@@ -1650,7 +1653,8 @@ export function ClaimForm({
patientId,
insuranceProvider: "United/DentalHub",
insuranceSiteKey: "UNITED_SCO",
claimFiles: udPreAuthFilesMeta,
appointmentId: appointmentId ?? 0,
claimFiles: mergeWithExistingAttachments(udPreAuthFilesMeta),
});
onClose();
@@ -1694,7 +1698,8 @@ export function ClaimForm({
patientId,
insuranceProvider: "Tufts SCO",
insuranceSiteKey: "TUFTS_SCO",
claimFiles: preAuthFilesMeta,
appointmentId: appointmentId ?? 0,
claimFiles: mergeWithExistingAttachments(preAuthFilesMeta),
});
onClose();
@@ -32,6 +32,15 @@ import {
} from "@repo/db/types";
import { z } from "zod";
import { DateInputField } from "@/components/ui/dateInputField";
import { PolicyHolderSearch } from "@/components/patients/policy-holder-search";
// ADA claim form box 18 — relationship to policyholder/subscriber
const POLICY_HOLDER_RELATIONSHIPS = [
{ value: "SELF", label: "Self" },
{ value: "SPOUSE", label: "Spouse" },
{ value: "DEPENDENT", label: "Dependent Child" },
{ value: "OTHER", label: "Other" },
];
// New England first (most patients), then the remaining states alphabetically
const US_STATES = [
@@ -53,6 +62,11 @@ interface PatientFormProps {
onSubmit: (data: InsertPatient | UpdatePatient) => void;
}
// Patients saved before the relationship field existed: no policy holder means the
// patient is the holder, so show Self (matches how the ADA form treats them).
const defaultRelationship = (p: Patient): string =>
p.policyHolderRelationship || (!(p.policyHolder ?? "").trim() ? "SELF" : "");
export type PatientFormRef = {
submit: () => void;
};
@@ -100,6 +114,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
? formatLocalDate(new Date(patient.dateOfBirth))
: null,
insuranceProvider: normalizeInsuranceProvider(patient.insuranceProvider),
policyHolderRelationship: defaultRelationship(patient),
};
}
return {
@@ -117,6 +132,8 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
insuranceId: extractedInfo?.insuranceId || "",
groupNumber: "",
policyHolder: "",
policyHolderPatientId: null,
policyHolderRelationship: "SELF",
allergies: "",
medicalConditions: "",
preferredLanguage: "English",
@@ -156,6 +173,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
? formatLocalDate(new Date(patient.dateOfBirth))
: null,
insuranceProvider: normalized,
policyHolderRelationship: defaultRelationship(patient),
};
form.reset(resetValues);
// Explicit setValue ensures the controlled Select re-renders with the new value
@@ -532,6 +550,41 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
)}
/>
<FormField
control={form.control}
name="policyHolderRelationship"
render={({ field }) => (
<FormItem>
<FormLabel>Relationship to Policy Holder/Subscriber</FormLabel>
<Select
value={field.value || ""}
onValueChange={(v) => {
field.onChange(v);
// Self: the patient is the policy holder, so drop any other holder
if (v === "SELF") {
form.setValue("policyHolder", "", { shouldDirty: true });
form.setValue("policyHolderPatientId" as any, null, { shouldDirty: true });
}
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select relationship" />
</SelectTrigger>
</FormControl>
<SelectContent>
{POLICY_HOLDER_RELATIONSHIPS.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="policyHolder"
@@ -539,7 +592,19 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
<FormItem>
<FormLabel>Policy Holder (if not self)</FormLabel>
<FormControl>
<Input {...field} value={field.value || ""} />
<PolicyHolderSearch
value={field.value || ""}
holderPatientId={(form.watch("policyHolderPatientId" as any) as number | null) ?? null}
excludePatientId={patient?.id}
onChange={(name, holderPatientId) => {
field.onChange(name);
form.setValue("policyHolderPatientId" as any, holderPatientId, { shouldDirty: true });
// Picking someone else means the patient isn't the holder
if (name.trim() && form.getValues("policyHolderRelationship" as any) === "SELF") {
form.setValue("policyHolderRelationship" as any, "", { shouldDirty: true });
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -1320,6 +1320,14 @@ export function PatientTable({
<span className="text-gray-500">Policy Holder:</span>{" "}
{currentPatient.policyHolder || "Self"}
</p>
{currentPatient.policyHolderRelationship && (
<p>
<span className="text-gray-500">Relationship:</span>{" "}
{({ SELF: "Self", SPOUSE: "Spouse", DEPENDENT: "Dependent Child", OTHER: "Other" } as Record<string, string>)[
currentPatient.policyHolderRelationship
] ?? currentPatient.policyHolderRelationship}
</p>
)}
</div>
</div>
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { apiRequest } from "@/lib/queryClient";
import { formatLocalDate } from "@/utils/dateUtils";
import type { Patient } from "@repo/db/types";
interface PolicyHolderSearchProps {
/** Policy holder name as stored on the patient (free text) */
value: string;
/** Linked patient record of the policy holder, if one was picked */
holderPatientId: number | null;
/** Patient being edited — hidden from the results */
excludePatientId?: number;
onChange: (name: string, holderPatientId: number | null) => void;
}
// Type to search patients in the DB and pick the policy holder; free text is still allowed
// for holders who aren't patients here (then holderPatientId is null).
export function PolicyHolderSearch({ value, holderPatientId, excludePatientId, onChange }: PolicyHolderSearchProps) {
const [open, setOpen] = useState(false);
const [debounced] = useDebounce(value.trim(), 300);
const wrapperRef = useRef<HTMLDivElement>(null);
// The search API matches one name field at a time, so query by the first word
// and narrow by the remaining words here ("john smith" → name=john, then must contain "smith").
const tokens = debounced.toLowerCase().split(/[\s,]+/).filter(Boolean);
const { data: results = [], isFetching } = useQuery<Patient[]>({
queryKey: ["policy-holder-search", tokens[0] ?? ""],
queryFn: async () => {
const res = await apiRequest(
"GET",
`/api/patients/search?name=${encodeURIComponent(tokens[0]!)}&limit=50&offset=0`,
);
if (!res.ok) return [];
const payload = await res.json();
return Array.isArray(payload) ? payload : (payload.patients ?? []);
},
enabled: open && tokens.length > 0 && holderPatientId == null,
});
const matches = results
.filter((p) => p.id !== excludePatientId)
.filter((p) => {
const full = `${p.firstName} ${p.lastName}`.toLowerCase();
return tokens.every((t) => full.includes(t));
})
.slice(0, 10);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, []);
return (
<div ref={wrapperRef} className="relative">
<Input
value={value}
placeholder="Search patient by name…"
autoComplete="off"
onFocus={() => setOpen(true)}
onChange={(e) => {
// Editing the text unlinks any previously selected patient
onChange(e.target.value, null);
setOpen(true);
}}
className={holderPatientId != null ? "pr-8 border-blue-400 bg-blue-50" : "pr-8"}
/>
{value && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={() => onChange("", null)}
title="Clear"
>
<X className="h-4 w-4" />
</button>
)}
{holderPatientId != null && (
<p className="mt-1 text-xs text-blue-600">Linked to patient record #{holderPatientId}</p>
)}
{open && holderPatientId == null && tokens.length > 0 && (
<div className="absolute z-50 mt-1 w-full max-h-60 overflow-y-auto rounded-md border bg-white shadow-md">
{isFetching && matches.length === 0 ? (
<div className="px-3 py-2 text-xs text-gray-500">Searching…</div>
) : matches.length === 0 ? (
<div className="px-3 py-2 text-xs text-gray-500">No matching patients — the name will be saved as typed.</div>
) : (
matches.map((p) => (
<button
key={p.id}
type="button"
className="block w-full px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => {
onChange(`${p.firstName} ${p.lastName}`.trim(), Number(p.id));
setOpen(false);
}}
>
<span className="font-medium">{p.firstName} {p.lastName}</span>
<span className="ml-2 text-xs text-gray-500">
{p.dateOfBirth ? `DOB ${formatLocalDate(new Date(p.dateOfBirth))}` : ""}
{p.insuranceId ? ` · ID ${p.insuranceId}` : ""}
</span>
</button>
))
)}
</div>
)}
</div>
);
}
+60 -14
View File
@@ -100,6 +100,7 @@ interface ScheduledAppointment {
patientInsuranceProvider: string | null;
hasProcedures: boolean;
hasClaimWithNumber: boolean;
hasPreauth: boolean;
movedByAi?: boolean;
staffId: number;
date: string | Date;
@@ -111,6 +112,17 @@ interface ScheduledAppointment {
procedureCodes?: string[];
}
// Insurances with an online PreAuth flow on the claims page (others can't be preauthorized there).
// Maps either site-key spelling (AI "TUFTS_SCO" / local "TuftsSCO") to the key the claim form expects.
const PREAUTH_SITE_KEYS: Record<string, string> = {
MH: "MH",
CCA: "CCA",
TUFTSSCO: "TUFTS_SCO",
UNITEDSCO: "UNITED_SCO",
};
const toPreauthSiteKey = (siteKey: string): string | undefined =>
PREAUTH_SITE_KEYS[siteKey.replace(/_/g, "").toUpperCase()];
function deriveInsuranceSiteKey(provider: string | null | undefined): string {
const p = (provider || "").toLowerCase().trim();
if (!p) return "";
@@ -384,6 +396,10 @@ export default function AppointmentsPage() {
color: colors[index % colors.length] || "bg-gray-400",
}));
// The last schedule column is the Preauth column: "Claim/Preauth for Column with AI"
// sends its appointments to PreAuth instead of Claim.
const preauthStaffId = staffMembers.length ? Number(staffMembers[staffMembers.length - 1]!.id) : null;
// Initialize column labels from localStorage; default A, B, C… for new staff
useEffect(() => {
if (!staffMembersRaw.length) return;
@@ -703,6 +719,7 @@ export default function AppointmentsPage() {
patientInsuranceProvider,
hasProcedures: !!(apt as any).hasProcedures,
hasClaimWithNumber: !!(apt as any).hasClaimWithNumber,
hasPreauth: !!(apt as any).hasPreauth,
notes: (apt as any).notes ?? null,
procedureCodes: (apt as any).procedureCodes ?? [],
movedByAi: !!(apt as any).movedByAi,
@@ -1654,10 +1671,12 @@ export default function AppointmentsPage() {
const handleClaimForColumnWithAi = async () => {
if (selectedClaimAiColumns.size === 0) return;
const unclaimed = processedAppointments.filter(
(apt) => selectedClaimAiColumns.has(apt.staffId) && !apt.hasClaimWithNumber
(apt) =>
selectedClaimAiColumns.has(apt.staffId) &&
(apt.staffId === preauthStaffId ? !apt.hasPreauth : !apt.hasClaimWithNumber)
);
if (!unclaimed.length) {
toast({ title: "No unclaimed appointments", description: "All appointments in the selected columns are already claimed." });
toast({ title: "Nothing to submit", description: "All appointments in the selected columns are already claimed / preauthorized." });
return;
}
const autoRun = autoConfirmEnabled || autoClaimEnabled;
@@ -1677,6 +1696,17 @@ export default function AppointmentsPage() {
const handleAiClaimConfirm = () => {
if (!aiClaimCurrentData) return;
const { matchedCodes, siteKey, serviceDate, appointmentId } = aiClaimCurrentData;
const isPreauth = aiClaimQueue[aiClaimCurrentIndex]?.staffId === preauthStaffId;
const preauthSiteKey = toPreauthSiteKey(siteKey);
if (isPreauth && !preauthSiteKey) {
toast({
title: "PreAuth not supported",
description: `${aiClaimCurrentData.patientName}: online PreAuth isn't available for ${siteKey || "this insurance"} — skipped.`,
variant: "destructive",
});
handleAiClaimSkip();
return;
}
// Include current patient in queue; claims-page advances it only on successful submit.
const fromCurrent = aiClaimQueue.slice(aiClaimCurrentIndex);
if (fromCurrent.length > 0) {
@@ -1689,14 +1719,24 @@ export default function AppointmentsPage() {
} else {
sessionStorage.removeItem("ai_claim_queue");
}
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({
codes: matchedCodes,
siteKey,
serviceDate,
autoSubmit: true,
}));
if (isPreauth) {
sessionStorage.removeItem("chatbot_claim_prefill");
sessionStorage.setItem("chatbot_preauth_prefill", JSON.stringify({
codes: matchedCodes,
siteKey: preauthSiteKey,
serviceDate,
}));
} else {
sessionStorage.removeItem("chatbot_preauth_prefill");
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({
codes: matchedCodes,
siteKey,
serviceDate,
autoSubmit: true,
}));
}
// BCBS is a paper form filled and saved in the background — come straight back here
if (/^bcbs/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1");
if (!isPreauth && /^bcbs/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1");
else sessionStorage.removeItem("ai_claim_return_to_schedule");
setAiClaimModalOpen(false);
setLocation(`/claims?appointmentId=${appointmentId}`);
@@ -1924,7 +1964,7 @@ export default function AppointmentsPage() {
) : (
<>
<Bot className="h-4 w-4 mr-1" />
Claim for Column with AI
Claim/Preauth for Column with AI
</>
)}
</Button>
@@ -2375,8 +2415,14 @@ export default function AppointmentsPage() {
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</div>
)}
<div className="text-xs text-gray-500">{staff.name}</div>
<div className="text-xs text-gray-400">{staff.role}</div>
{Number(staff.id) === preauthStaffId ? (
<div className="text-xs font-semibold text-blue-600">Preauth</div>
) : (
<>
<div className="text-xs text-gray-500">{staff.name}</div>
<div className="text-xs text-gray-400">{staff.role}</div>
</>
)}
</th>
))}
</tr>
@@ -2516,7 +2562,7 @@ export default function AppointmentsPage() {
<div className="p-4 border-b flex items-center justify-between">
<h3 className="font-semibold text-base flex items-center gap-2">
<Bot className="h-4 w-4 text-teal-600" />
AI Claim Queue
AI Claim/Preauth Queue
</h3>
<span className="text-xs text-gray-500 font-medium">
{aiClaimAutoRun && (
@@ -2626,7 +2672,7 @@ export default function AppointmentsPage() {
onClick={handleAiClaimConfirm}
>
<FileCheck className="h-3.5 w-3.5" />
Confirm &amp; Claim
{aiClaimQueue[aiClaimCurrentIndex]?.staffId === preauthStaffId ? "Confirm & PreAuth" : "Confirm & Claim"}
</button>
)}
<button
+13
View File
@@ -325,6 +325,15 @@ export default function ClaimsPage() {
}
}
} catch {}
// Preauth column of "Claim/Preauth for Column with AI": open the PreAuth tab and auto-submit
try {
const preauthRaw = sessionStorage.getItem("chatbot_preauth_prefill");
if (preauthRaw) {
setChatbotInitialTab("preauth");
const parsed = JSON.parse(preauthRaw);
if (parsed?.siteKey) setChatbotAutoSubmitPreauthSiteKey(parsed.siteKey);
}
} catch {}
handleNewClaim(patientId, appointmentId);
clearUrlParams(["appointmentId"]);
}
@@ -761,6 +770,7 @@ export default function ClaimsPage() {
// United/DentalHub pre-auth selenium handler
const handleUnitedDHPreAuthSubmitSelenium = async (data: any) => {
advanceAiClaimQueue();
try {
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "Submitting United/DentalHub pre-authorization..." }));
const response = await apiRequest("POST", "/api/claims/uniteddh-preauth", {
@@ -826,6 +836,7 @@ export default function ClaimsPage() {
// Tufts SCO pre-auth selenium handler
const handleTuftsSCOPreAuthSubmitSelenium = async (data: any) => {
advanceAiClaimQueue();
try {
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "Submitting Tufts SCO pre-authorization..." }));
const response = await apiRequest("POST", "/api/claims/tuftssco-preauth", {
@@ -1044,6 +1055,7 @@ export default function ClaimsPage() {
// CCA pre-auth selenium handler
const handleCCAPreAuthSubmitSelenium = async (data: any) => {
advanceAiClaimQueue();
const formData = new FormData();
formData.append("data", JSON.stringify(data));
if (socketId) formData.append("socketId", socketId);
@@ -1203,6 +1215,7 @@ export default function ClaimsPage() {
// Pre Auth section
const handleMHClaimPreAuthSubmitSelenium = async (data: any) => {
advanceAiClaimQueue();
const formData = new FormData();
formData.append("data", JSON.stringify(data));
if (socketId) formData.append("socketId", socketId);