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
@@ -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);