diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts
index e0720dc9..a25d7b88 100644
--- a/apps/Backend/src/ai/internal-chat-graph.ts
+++ b/apps/Backend/src/ai/internal-chat-graph.ts
@@ -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);
diff --git a/apps/Backend/src/routes/appointments.ts b/apps/Backend/src/routes/appointments.ts
index 95edd554..759201d9 100755
--- a/apps/Backend/src/routes/appointments.ts
+++ b/apps/Backend/src/routes/appointments.ts
@@ -102,16 +102,18 @@ router.get("/day", async (req: Request, res: Response): Promise => {
// 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) ?? []) : [],
}));
diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts
index 48a7ce3f..1da7fa9b 100755
--- a/apps/Backend/src/routes/claims.ts
+++ b/apps/Backend/src/routes/claims.ts
@@ -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 {
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,
diff --git a/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts b/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts
index ad5bd2b9..f92ab1ad 100644
--- a/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts
+++ b/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts
@@ -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: {
diff --git a/apps/Backend/src/services/adaClaimForm.ts b/apps/Backend/src/services/adaClaimForm.ts
index aad98175..ec1f0dd4 100644
--- a/apps/Backend/src/services/adaClaimForm.ts
+++ b/apps/Backend/src/services/adaClaimForm.ts
@@ -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);
diff --git a/apps/Backend/src/storage/claims-storage.ts b/apps/Backend/src/storage/claims-storage.ts
index 158a07d1..a064903c 100755
--- a/apps/Backend/src/storage/claims-storage.ts
+++ b/apps/Backend/src/storage/claims-storage.ts
@@ -24,6 +24,7 @@ export interface IStorage {
updateClaimStatus(id: number, status: ClaimStatus): Promise;
deleteClaim(id: number): Promise;
getAppointmentIdsWithClaimNumbers(ids: number[]): Promise>;
+ getAppointmentIdsWithPreauth(ids: number[]): Promise>;
}
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> {
+ 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));
+ },
};
diff --git a/apps/Backend/src/utils/appointmentAttachments.ts b/apps/Backend/src/utils/appointmentAttachments.ts
new file mode 100644
index 00000000..c0b648b1
--- /dev/null
+++ b/apps/Backend/src/utils/appointmentAttachments.ts
@@ -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 {
+ 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);
+ }
+}
diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx
index 3210485d..1536b6b8 100755
--- a/apps/Frontend/src/components/claims/claim-form.tsx
+++ b/apps/Frontend/src/components/claims/claim-form.tsx
@@ -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();
diff --git a/apps/Frontend/src/components/patients/patient-form.tsx b/apps/Frontend/src/components/patients/patient-form.tsx
index 49fde95d..c36cd707 100755
--- a/apps/Frontend/src/components/patients/patient-form.tsx
+++ b/apps/Frontend/src/components/patients/patient-form.tsx
@@ -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(
? formatLocalDate(new Date(patient.dateOfBirth))
: null,
insuranceProvider: normalizeInsuranceProvider(patient.insuranceProvider),
+ policyHolderRelationship: defaultRelationship(patient),
};
}
return {
@@ -117,6 +132,8 @@ export const PatientForm = forwardRef(
insuranceId: extractedInfo?.insuranceId || "",
groupNumber: "",
policyHolder: "",
+ policyHolderPatientId: null,
+ policyHolderRelationship: "SELF",
allergies: "",
medicalConditions: "",
preferredLanguage: "English",
@@ -156,6 +173,7 @@ export const PatientForm = forwardRef(
? 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(
)}
/>
+ (
+
+ Relationship to Policy Holder/Subscriber
+
+
+
+ )}
+ />
+
(
Policy Holder (if not self)
-
+ {
+ 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 });
+ }
+ }}
+ />
diff --git a/apps/Frontend/src/components/patients/patient-table.tsx b/apps/Frontend/src/components/patients/patient-table.tsx
index 32ec5399..cb27af1c 100755
--- a/apps/Frontend/src/components/patients/patient-table.tsx
+++ b/apps/Frontend/src/components/patients/patient-table.tsx
@@ -1320,6 +1320,14 @@ export function PatientTable({
Policy Holder:{" "}
{currentPatient.policyHolder || "Self"}
+ {currentPatient.policyHolderRelationship && (
+
+ Relationship:{" "}
+ {({ SELF: "Self", SPOUSE: "Spouse", DEPENDENT: "Dependent Child", OTHER: "Other" } as Record)[
+ currentPatient.policyHolderRelationship
+ ] ?? currentPatient.policyHolderRelationship}
+
+ )}
diff --git a/apps/Frontend/src/components/patients/policy-holder-search.tsx b/apps/Frontend/src/components/patients/policy-holder-search.tsx
new file mode 100644
index 00000000..55819587
--- /dev/null
+++ b/apps/Frontend/src/components/patients/policy-holder-search.tsx
@@ -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(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({
+ 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 (
+
+
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 && (
+
+ )}
+ {holderPatientId != null && (
+
Linked to patient record #{holderPatientId}
+ )}
+ {open && holderPatientId == null && tokens.length > 0 && (
+
+ {isFetching && matches.length === 0 ? (
+
Searching…
+ ) : matches.length === 0 ? (
+
No matching patients — the name will be saved as typed.
+ ) : (
+ matches.map((p) => (
+
+ ))
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx
index a167e746..6827c95a 100755
--- a/apps/Frontend/src/pages/appointments-page.tsx
+++ b/apps/Frontend/src/pages/appointments-page.tsx
@@ -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 = {
+ 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() {
) : (
<>
- Claim for Column with AI
+ Claim/Preauth for Column with AI
>
)}
@@ -2375,8 +2415,14 @@ export default function AppointmentsPage() {
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
)}
- {staff.name}
- {staff.role}
+ {Number(staff.id) === preauthStaffId ? (
+ Preauth
+ ) : (
+ <>
+ {staff.name}
+ {staff.role}
+ >
+ )}
))}
@@ -2516,7 +2562,7 @@ export default function AppointmentsPage() {
- AI Claim Queue
+ AI Claim/Preauth Queue
{aiClaimAutoRun && (
@@ -2626,7 +2672,7 @@ export default function AppointmentsPage() {
onClick={handleAiClaimConfirm}
>
- Confirm & Claim
+ {aiClaimQueue[aiClaimCurrentIndex]?.staffId === preauthStaffId ? "Confirm & PreAuth" : "Confirm & Claim"}
)}