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:
@@ -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);
|
||||
|
||||
@@ -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) ?? []) : [],
|
||||
}));
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & Claim
|
||||
{aiClaimQueue[aiClaimCurrentIndex]?.staffId === preauthStaffId ? "Confirm & PreAuth" : "Confirm & Claim"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user