feat: teach AI classifier to recognize "comp" and tooth surface notation

Add "comp" as alias for composite in CDT lookup. Update classifier
prompt with explicit examples for "claim comp #8 ml for lisa today"
and dental surface letter definitions (M/D/L/F/B/O/V/I) so the LLM
correctly treats #tooth+surfaces as composite fillings, not insurance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 20:55:41 -04:00
parent fc3e8c0e25
commit c9d08028a9
8 changed files with 519 additions and 92 deletions

View File

@@ -0,0 +1,156 @@
import React, { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import type { PatientBalanceRow } from "@repo/db/types";
import PatientsBalancesList from "./patients-balances-list";
import ExportReportButton from "./export-button";
type Resp = {
balances: PatientBalanceRow[];
totalCount?: number;
nextCursor?: string | null;
hasMore?: boolean;
totalCharges?: number;
totalCollected?: number;
};
function fmtCurrency(v: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(v);
}
export default function PatientsNoBalanceReport({
startDate,
endDate,
npiProviderId,
}: {
startDate: string;
endDate: string;
npiProviderId?: number | null;
}) {
const balancesPerPage = 10;
const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]);
const [cursorIndex, setCursorIndex] = useState(0);
const currentCursor = cursorStack[cursorIndex] ?? null;
const pageIndex = cursorIndex + 1;
const { data, isLoading, isError, refetch } = useQuery<Resp, Error>({
queryKey: [
"/api/payments-reports/patients-with-zero-balances",
currentCursor,
balancesPerPage,
startDate,
endDate,
npiProviderId ?? "all",
],
queryFn: async () => {
const params = new URLSearchParams();
params.set("limit", String(balancesPerPage));
if (currentCursor) params.set("cursor", currentCursor);
if (startDate) params.set("from", startDate);
if (endDate) params.set("to", endDate);
if (npiProviderId) params.set("npiProviderId", String(npiProviderId));
const res = await apiRequest(
"GET",
`/api/payments-reports/patients-with-zero-balances?${params.toString()}`
);
if (!res.ok) {
const body = await res.json().catch(() => ({ message: "Failed to load" }));
throw new Error(body.message || "Failed to load zero-balance patients");
}
return res.json();
},
enabled: true,
});
const balances = data?.balances ?? [];
const totalCount = data?.totalCount ?? undefined;
const nextCursor = data?.nextCursor ?? null;
const hasMore = data?.hasMore ?? false;
const totalCharges = data?.totalCharges ?? 0;
const totalCollected = data?.totalCollected ?? 0;
useEffect(() => {
setCursorStack([null]);
setCursorIndex(0);
refetch();
}, [startDate, endDate, npiProviderId]);
const handleNext = useCallback(() => {
const isLastKnown = cursorIndex === cursorStack.length - 1;
if (isLastKnown) {
if (nextCursor) {
setCursorStack((s) => [...s, nextCursor]);
setCursorIndex((i) => i + 1);
}
} else {
setCursorIndex((i) => i + 1);
}
}, [cursorIndex, cursorStack.length, nextCursor]);
const handlePrev = useCallback(() => {
setCursorIndex((i) => Math.max(0, i - 1));
}, []);
const normalized = balances.map((b) => {
const currentBalance = Number(b.currentBalance ?? 0);
const totalChargesRow = Number(b.totalCharges ?? 0);
const totalPayments =
b.totalPayments != null
? Number(b.totalPayments)
: Number(totalChargesRow - currentBalance);
return {
id: b.patientId,
name: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(),
currentBalance,
totalCharges: totalChargesRow,
totalPayments,
};
});
return (
<div>
<div className="mb-4">
<PatientsBalancesList
rows={normalized}
reportType="patients_no_balance"
loading={isLoading}
error={isError ? "Failed to load zero-balance patients." : false}
emptyMessage="No patients with zero balance for the selected date range."
pageIndex={pageIndex}
perPage={balancesPerPage}
total={totalCount}
onPrev={handlePrev}
onNext={handleNext}
hasPrev={cursorIndex > 0}
hasNext={hasMore}
headerRight={
<ExportReportButton
reportType="patients_no_balance"
from={startDate}
to={endDate}
className="mr-2"
/>
}
/>
</div>
{/* Aggregate summary for the full zero-balance set */}
{!isLoading && !isError && (totalCount ?? 0) > 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg px-5 py-3 flex flex-wrap gap-6 text-sm">
<div>
<span className="text-gray-500">Total patients paid in full: </span>
<span className="font-semibold text-green-700">{totalCount}</span>
</div>
<div>
<span className="text-gray-500">Total charges billed: </span>
<span className="font-semibold text-gray-800">{fmtCurrency(totalCharges)}</span>
</div>
<div>
<span className="text-gray-500">Total collected: </span>
<span className="font-semibold text-green-700">{fmtCurrency(totalCollected)}</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -10,21 +10,28 @@ type SummaryResp = {
totalCollected?: number;
};
type ZeroBalResp = {
totalCount?: number;
totalCharges?: number;
totalCollected?: number;
};
type ReportType = string;
function fmtCurrency(v: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(v);
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(v);
}
export default function SummaryCards({
startDate,
endDate,
npiProviderId,
selectedReportType,
}: {
startDate: string;
endDate: string;
npiProviderId?: number | null;
selectedReportType?: ReportType;
}) {
const { data, isLoading, isError } = useQuery<SummaryResp, Error>({
queryKey: ["/api/payments-reports/summary", startDate, endDate, npiProviderId ?? "all"],
@@ -33,12 +40,9 @@ export default function SummaryCards({
if (startDate) params.set("from", startDate);
if (endDate) params.set("to", endDate);
if (npiProviderId) params.set("npiProviderId", String(npiProviderId));
const endpoint = `/api/payments-reports/summary?${params.toString()}`;
const res = await apiRequest("GET", endpoint);
const res = await apiRequest("GET", `/api/payments-reports/summary?${params.toString()}`);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ message: "Failed to load dashboard summary" }));
const body = await res.json().catch(() => ({ message: "Failed to load dashboard summary" }));
throw new Error(body?.message ?? "Failed to load dashboard summary");
}
return res.json();
@@ -46,29 +50,44 @@ export default function SummaryCards({
enabled: Boolean(startDate && endDate),
});
const totalPatients = data?.totalPatients ?? 0;
// Fetch zero-balance aggregates only when that report is active
const showZeroBalSummary = selectedReportType === "patients_no_balance";
const { data: zeroBalData, isLoading: zeroBalLoading } = useQuery<ZeroBalResp, Error>({
queryKey: ["/api/payments-reports/patients-with-zero-balances/summary", startDate, endDate, npiProviderId ?? "all"],
queryFn: async () => {
const params = new URLSearchParams();
params.set("limit", "1");
if (startDate) params.set("from", startDate);
if (endDate) params.set("to", endDate);
if (npiProviderId) params.set("npiProviderId", String(npiProviderId));
const res = await apiRequest("GET", `/api/payments-reports/patients-with-zero-balances?${params.toString()}`);
if (!res.ok) throw new Error("Failed to load zero balance summary");
return res.json();
},
enabled: Boolean(startDate && endDate && showZeroBalSummary),
});
const totalPatients = data?.totalPatients ?? 0;
const patientsWithBalance = data?.patientsWithBalance ?? 0;
const patientsNoBalance = Math.max(
0,
(data?.totalPatients ?? 0) - (data?.patientsWithBalance ?? 0)
);
const totalOutstanding = data?.totalOutstanding ?? 0;
const totalCollected = data?.totalCollected ?? 0;
const patientsNoBalance = Math.max(0, totalPatients - patientsWithBalance);
const totalOutstanding = data?.totalOutstanding ?? 0;
const totalCollected = data?.totalCollected ?? 0;
const zeroBalCount = zeroBalData?.totalCount ?? 0;
const zeroBalBilled = zeroBalData?.totalCharges ?? 0;
const zeroBalCollected = zeroBalData?.totalCollected ?? 0;
const isWithBalance = selectedReportType === "patients_with_balance";
return (
<Card className="pt-4 pb-4">
<CardContent>
{/* Heading */}
<div className="mb-3">
<h2 className="text-base font-semibold text-gray-800">
Report summary
</h2>
<p className="text-sm text-gray-500">
Data covers the selected time frame
</p>
<h2 className="text-base font-semibold text-gray-800">Report summary</h2>
<p className="text-sm text-gray-500">Data covers the selected time frame</p>
</div>
{/* Stats grid */}
{/* Global stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">
@@ -77,21 +96,21 @@ export default function SummaryCards({
<p className="text-sm text-gray-600">Total Patients</p>
</div>
<div className="text-center">
<div className={`text-center rounded-lg py-1 ${isWithBalance ? "bg-red-50 ring-1 ring-red-300" : ""}`}>
<div className="text-lg font-semibold text-red-600">
{isLoading ? "—" : patientsWithBalance}
</div>
<p className="text-sm text-gray-600">With Balance</p>
</div>
<div className="text-center">
<div className={`text-center rounded-lg py-1 ${showZeroBalSummary ? "bg-green-50 ring-1 ring-green-300" : ""}`}>
<div className="text-lg font-semibold text-green-600">
{isLoading ? "—" : patientsNoBalance}
</div>
<p className="text-sm text-gray-600">Zero Balance</p>
</div>
<div className="text-center">
<div className={`text-center rounded-lg py-1 ${isWithBalance ? "bg-orange-50 ring-1 ring-orange-300" : ""}`}>
<div className="text-lg font-semibold text-orange-600">
{isLoading ? "—" : fmtCurrency(totalOutstanding)}
</div>
@@ -106,6 +125,35 @@ export default function SummaryCards({
</div>
</div>
{/* Zero-balance detail row */}
{showZeroBalSummary && (
<div className="mt-4 pt-4 border-t border-green-100">
<p className="text-xs font-semibold text-green-700 uppercase tracking-wide mb-2">
Zero Balance Paid in Full Summary
</p>
<div className="grid grid-cols-3 gap-4">
<div className="text-center bg-green-50 rounded-lg py-2">
<div className="text-lg font-semibold text-green-700">
{zeroBalLoading ? "—" : zeroBalCount}
</div>
<p className="text-xs text-gray-600">Patients Paid in Full</p>
</div>
<div className="text-center bg-green-50 rounded-lg py-2">
<div className="text-lg font-semibold text-gray-700">
{zeroBalLoading ? "—" : fmtCurrency(zeroBalBilled)}
</div>
<p className="text-xs text-gray-600">Total Billed</p>
</div>
<div className="text-center bg-green-50 rounded-lg py-2">
<div className="text-lg font-semibold text-green-700">
{zeroBalLoading ? "—" : fmtCurrency(zeroBalCollected)}
</div>
<p className="text-xs text-gray-600">Total Collected</p>
</div>
</div>
</div>
)}
{isError && (
<div className="mt-3 text-sm text-red-600">
Failed to load summary. Check server or network.