feat: chatbot rendering provider override and NPI provider ordering

- AI chat extracts 'with provider <name>' and routes claim to that provider
- Claim form reads provider from sessionStorage before any async effects run,
  preventing saved claim/procedure data from overriding the chatbot selection
- NPI provider settings table shows Provider #1 / #2 labels with up/down
  reorder buttons; Provider #1 is always the default for claims
- Default provider now uses sortOrder instead of hardcoded 'Mary Scannell'
- Added sortOrder column to NpiProvider schema with migration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-06-11 13:17:05 -04:00
parent d4b9c1b889
commit 75c49ab1df
77 changed files with 385 additions and 105 deletions

View File

@@ -22,6 +22,8 @@ export interface ChatClassification {
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
// --- insurance hint (only if explicitly stated in the message) ---
insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA"
// --- rendering/treating provider (only if explicitly stated, e.g. "with provider Kai Gao") ---
renderingProvider?: string; // raw name, e.g. "Kai Gao", "Dr. Smith"
// --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) ---
procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"]
// --- scheduling ---
@@ -46,6 +48,7 @@ Respond ONLY with valid JSON (no markdown fences):
"memberId": "<member/insurance ID if given explicitly or found in history>",
"dob": "<date of birth in MM/DD/YYYY if given explicitly or found in history>",
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"renderingProvider": "<provider/doctor name only if explicitly stated, e.g. 'Kai Gao', 'Dr. Smith' — omit if not mentioned>",
"procedureNames": ["<raw procedure name>", ...],
"appointmentDate": "<YYYY-MM-DD; use today's date (${today}) if user says 'today'; omit only if no date is mentioned at all>",
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
@@ -104,6 +107,9 @@ Rules:
e.g. "3 PA (#3, 14, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #30"]
e.g. "2 pa #3 #14" → ["1 pa, #3", "2nd pa, #14"]
- 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"
Extract just the name (without "Dr." prefix unless it's part of the name), omit if not mentioned
- Keep fallbackReply to 1-2 sentences
- For navigate intents, fallbackReply = "Opening the [page] page..." (e.g. "Opening the eligibility page...")
- appointmentDate applies to BOTH schedule_appointment AND claim_only/check_and_claim:

View File

@@ -594,6 +594,7 @@ async function handleClaimOnly(
serviceDate,
appointmentId,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface })),
renderingProvider: c.renderingProvider ?? null,
},
};
}

View File

@@ -73,6 +73,22 @@ router.put("/:id", async (req: Request, res: Response) => {
}
});
router.post("/reorder", async (req: Request, res: Response) => {
try {
if (!req.user?.id) {
return res.status(401).json({ message: "Unauthorized" });
}
const { orderedIds } = req.body;
if (!Array.isArray(orderedIds)) {
return res.status(400).json({ message: "orderedIds must be an array" });
}
await storage.reorderNpiProviders(req.user.id, orderedIds.map(Number));
res.status(200).json({ ok: true });
} catch (err) {
res.status(500).json({ error: "Failed to reorder NPI providers", details: String(err) });
}
});
router.delete("/:id", async (req: Request, res: Response) => {
try {
if (!req.user?.id) {

View File

@@ -10,6 +10,7 @@ export interface INpiProviderStorage {
updates: Partial<NpiProvider>,
): Promise<NpiProvider | null>;
deleteNpiProvider(userId: number, id: number): Promise<boolean>;
reorderNpiProviders(userId: number, orderedIds: number[]): Promise<void>;
}
export const npiProviderStorage: INpiProviderStorage = {
@@ -20,7 +21,7 @@ export const npiProviderStorage: INpiProviderStorage = {
async getNpiProvidersByUser(userId: number) {
return db.npiProvider.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
});
},
@@ -47,4 +48,15 @@ export const npiProviderStorage: INpiProviderStorage = {
return false;
}
},
async reorderNpiProviders(userId: number, orderedIds: number[]) {
await Promise.all(
orderedIds.map((id, index) =>
db.npiProvider.update({
where: { id, userId },
data: { sortOrder: index + 1 },
})
)
);
},
};

View File

@@ -124,6 +124,16 @@ export function ClaimForm({
const [prefillDone, setPrefillDone] = useState(false);
const autoSubmittedRef = useRef(false);
// Read chatbot-requested rendering provider synchronously at mount (before any effects run)
// so the npiProviders effect always sees it, even when the provider list is already cached.
const [chatbotRenderingProvider] = useState<string | null>(() => {
try {
const raw = sessionStorage.getItem("chatbot_claim_prefill");
if (!raw) return null;
const parsed = JSON.parse(raw);
return (parsed?.renderingProvider as string | null | undefined) ?? null;
} catch { return null; }
});
// When an existing claim is loaded for the appointment, store its ID so
// the form submits an update instead of creating a new claim.
const [existingClaimId, setExistingClaimId] = useState<number | null>(null);
@@ -190,14 +200,27 @@ export function ClaimForm({
useEffect(() => {
if (!npiProviders.length) return;
// do not override if user already selected
// If chatbot specified a rendering provider, apply it (takes priority over default)
if (chatbotRenderingProvider) {
const needle = chatbotRenderingProvider.toLowerCase();
const matched = npiProviders.find(
(p) =>
p.providerName.toLowerCase().includes(needle) ||
needle.includes(p.providerName.toLowerCase()),
);
if (matched) {
setForm((prev) => ({
...prev,
npiProvider: { npiNumber: matched.npiNumber, providerName: matched.providerName },
}));
return;
}
}
// Do not override if user already selected (or chatbot already applied above)
if (form.npiProvider?.npiNumber) return;
const maryScannell = npiProviders.find(
(p) => p.providerName.toLowerCase() === "mary scannell",
);
const fallback = maryScannell || npiProviders[0];
const fallback = npiProviders[0];
if (fallback) {
setForm((prev) => ({
@@ -208,7 +231,7 @@ export function ClaimForm({
},
}));
}
}, [npiProviders]);
}, [npiProviders, chatbotRenderingProvider]);
// Service date state
const [serviceDateValue, setServiceDateValue] = useState<Date>(new Date());
@@ -401,8 +424,8 @@ export function ClaimForm({
if (matchedStaff) setStaff(matchedStaff);
}
// Restore NPI provider selection
if ((claim as any).npiProviderId && npiProviders.length > 0) {
// Restore NPI provider selection — chatbot override takes priority
if ((claim as any).npiProviderId && npiProviders.length > 0 && !chatbotRenderingProvider) {
const matchedNpi = npiProviders.find(
(p) => Number(p.id) === Number((claim as any).npiProviderId),
);
@@ -466,8 +489,8 @@ export function ClaimForm({
: {}),
}));
// Restore NPI provider from saved procedures
if (data.npiProviderId) {
// Restore NPI provider from saved procedures — chatbot override takes priority
if (data.npiProviderId && !chatbotRenderingProvider) {
const npiId = Number(data.npiProviderId);
setSavedProcNpiId(npiId);
// Apply immediately if providers are already loaded
@@ -503,9 +526,10 @@ export function ClaimForm({
if (!raw) return;
try {
const { codes, serviceDate } = JSON.parse(raw) as {
const { codes, serviceDate, renderingProvider } = JSON.parse(raw) as {
codes: { code: string; description: string; toothNumber?: string; toothSurface?: string }[];
serviceDate?: string;
renderingProvider?: string | null;
};
sessionStorage.removeItem("chatbot_claim_prefill");
if (!codes?.length) return;
@@ -879,7 +903,12 @@ export function ClaimForm({
const appointmentData = {
patientId: patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
staffId: appointmentStaffId ?? staff?.id ?? 1,
title: serviceDate,
startTime: "09:00",
endTime: "09:30",
type: "recall",
status: "scheduled",
};
const created = await onHandleAppointmentSubmit(appointmentData);
@@ -1033,7 +1062,12 @@ export function ClaimForm({
const created = await onHandleAppointmentSubmit({
patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
staffId: appointmentStaffId ?? staff?.id ?? 1,
title: serviceDate,
startTime: "09:00",
endTime: "09:30",
type: "recall",
status: "scheduled",
});
if (typeof created === "number" && created > 0) {
appointmentIdToUse = created;
@@ -1112,7 +1146,12 @@ export function ClaimForm({
const created = await onHandleAppointmentSubmit({
patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
staffId: appointmentStaffId ?? staff?.id ?? 1,
title: serviceDate,
startTime: "09:00",
endTime: "09:30",
type: "recall",
status: "scheduled",
});
if (typeof created === "number" && created > 0) {
appointmentIdToUse = created;
@@ -1191,7 +1230,12 @@ export function ClaimForm({
const created = await onHandleAppointmentSubmit({
patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
staffId: appointmentStaffId ?? staff?.id ?? 1,
title: serviceDate,
startTime: "09:00",
endTime: "09:30",
type: "recall",
status: "scheduled",
});
if (typeof created === "number" && created > 0) {
appointmentIdToUse = created;
@@ -1269,7 +1313,12 @@ export function ClaimForm({
const created = await onHandleAppointmentSubmit({
patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
staffId: appointmentStaffId ?? staff?.id ?? 1,
title: serviceDate,
startTime: "09:00",
endTime: "09:30",
type: "recall",
status: "scheduled",
});
if (typeof created === "number" && created > 0) {
appointmentIdToUse = created;
@@ -1463,7 +1512,12 @@ export function ClaimForm({
const appointmentData = {
patientId: patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
staffId: appointmentStaffId ?? staff?.id ?? 1,
title: serviceDate,
startTime: "09:00",
endTime: "09:30",
type: "recall",
status: "scheduled",
};
const created = await onHandleAppointmentSubmit(appointmentData);
if (typeof created === "number" && created > 0) {
@@ -1613,7 +1667,12 @@ export function ClaimForm({
const created = await onHandleAppointmentSubmit({
patientId: patientId,
date: serviceDate,
staffId: appointmentStaffId ?? staff?.id,
staffId: appointmentStaffId ?? staff?.id ?? 1,
title: serviceDate,
startTime: "09:00",
endTime: "09:30",
type: "recall",
status: "scheduled",
});
if (typeof created === "number" && created > 0) {
appointmentIdToUse = created;
@@ -1690,6 +1749,7 @@ export function ClaimForm({
!!form.memberId?.trim() &&
!!form.dateOfBirth?.trim() &&
!!form.patientName?.trim() &&
!!form.npiProvider?.npiNumber &&
Array.isArray(form.serviceLines) &&
form.serviceLines.some(
(l) => l.procedureCode && l.procedureCode.trim() !== "",
@@ -1700,6 +1760,7 @@ export function ClaimForm({
form.memberId,
form.dateOfBirth,
form.patientName,
form.npiProvider,
form.serviceLines,
]);

View File

@@ -155,6 +155,7 @@ export function ChatbotButton() {
siteKey: string;
serviceDate: string;
appointmentId: number | null;
renderingProvider: string | null;
} | null>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [, setLocation] = useLocation();
@@ -454,13 +455,14 @@ export function ChatbotButton() {
}
if (data.action === "claim_only_ready" && data.actionData) {
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
const { patient, matchedCodes, siteKey, serviceDate, appointmentId, renderingProvider } = data.actionData;
setClaimReadyData({
patient: patient ?? null,
matchedCodes: matchedCodes ?? [],
siteKey,
serviceDate,
appointmentId: appointmentId ?? null,
renderingProvider: renderingProvider ?? null,
});
setStep("claim-ready");
return;
@@ -885,13 +887,13 @@ export function ChatbotButton() {
size="sm"
className="flex-1 h-8 text-xs bg-green-600 hover:bg-green-700 text-white"
onClick={() => {
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = claimReadyData;
const { patient, matchedCodes, siteKey, serviceDate, appointmentId, renderingProvider } = claimReadyData;
addMsg("user", "Confirm & submit claim");
addMsg("bot", "Opening claim...");
if (patient?.id && matchedCodes.length > 0) {
sessionStorage.setItem(
"chatbot_claim_prefill",
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true, renderingProvider: renderingProvider ?? null })
);
}
setChatbotPendingFiles(pendingFiles);
@@ -956,9 +958,9 @@ export function ChatbotButton() {
const data = await res.json();
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
if (data.action === "claim_only_ready" && data.actionData) {
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
const { patient, matchedCodes, siteKey, serviceDate, appointmentId, renderingProvider } = data.actionData;
if (patient?.id && matchedCodes?.length > 0) {
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true }));
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true, renderingProvider: renderingProvider ?? null }));
}
setChatbotPendingFiles(pendingFiles);
const url = appointmentId ? `/claims?appointmentId=${appointmentId}` : `/claims?newPatient=${patient?.id}`;

View File

@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "../ui/button";
import { Edit, Delete, Plus } from "lucide-react";
import { Edit, Delete, Plus, ChevronUp, ChevronDown } from "lucide-react";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import { NpiProviderForm } from "./npiProviderForm";
@@ -10,6 +10,7 @@ type NpiProvider = {
id: number;
npiNumber: string;
providerName: string;
sortOrder: number;
};
export function NpiProviderTable() {
@@ -17,20 +18,13 @@ export function NpiProviderTable() {
const [currentPage, setCurrentPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editingProvider, setEditingProvider] =
useState<NpiProvider | null>(null);
const [editingProvider, setEditingProvider] = useState<NpiProvider | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [providerToDelete, setProviderToDelete] =
useState<NpiProvider | null>(null);
const [providerToDelete, setProviderToDelete] = useState<NpiProvider | null>(null);
const providersPerPage = 5;
const {
data: providers = [],
isLoading,
error,
} = useQuery({
const { data: providers = [], isLoading, error } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
@@ -39,22 +33,35 @@ export function NpiProviderTable() {
},
});
const reorderMutation = useMutation({
mutationFn: async (orderedIds: number[]) => {
const res = await apiRequest("POST", "/api/npiProviders/reorder", { orderedIds });
if (!res.ok) throw new Error("Failed to reorder NPI providers");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/npiProviders/"] });
},
});
const deleteMutation = useMutation({
mutationFn: async (provider: NpiProvider) => {
const res = await apiRequest(
"DELETE",
`/api/npiProviders/${provider.id}`
);
const res = await apiRequest("DELETE", `/api/npiProviders/${provider.id}`);
if (!res.ok) throw new Error("Failed to delete NPI provider");
return true;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["/api/npiProviders/"],
});
queryClient.invalidateQueries({ queryKey: ["/api/npiProviders/"] });
},
});
const handleMove = (index: number, direction: "up" | "down") => {
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= providers.length) return;
const newOrder = [...providers];
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex]!, newOrder[index]!];
reorderMutation.mutate(newOrder.map((p) => p.id));
};
const handleDeleteClick = (provider: NpiProvider) => {
setProviderToDelete(provider);
setIsDeleteDialogOpen(true);
@@ -62,7 +69,6 @@ export function NpiProviderTable() {
const handleConfirmDelete = () => {
if (!providerToDelete) return;
deleteMutation.mutate(providerToDelete, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
@@ -73,18 +79,13 @@ export function NpiProviderTable() {
const indexOfLast = currentPage * providersPerPage;
const indexOfFirst = indexOfLast - providersPerPage;
const currentProviders = providers.slice(
indexOfFirst,
indexOfLast
);
const currentProviders = providers.slice(indexOfFirst, indexOfLast);
const totalPages = Math.ceil(providers.length / providersPerPage);
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
NPI Providers
</h2>
<h2 className="text-lg font-semibold text-gray-900">NPI Providers</h2>
<Button
onClick={() => {
setEditingProvider(null);
@@ -99,64 +100,99 @@ export function NpiProviderTable() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase w-28">
Provider #
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
NPI Number
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Provider Name
</th>
<th className="px-4 py-2" />
<th className="px-4 py-2 w-32" />
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (
<tr>
<td colSpan={3} className="text-center py-4">
<td colSpan={4} className="text-center py-4">
Loading NPI providers...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={3} className="text-center py-4 text-red-600">
<td colSpan={4} className="text-center py-4 text-red-600">
Error loading NPI providers
</td>
</tr>
) : currentProviders.length === 0 ? (
<tr>
<td colSpan={3} className="text-center py-4">
<td colSpan={4} className="text-center py-4">
No NPI providers found.
</td>
</tr>
) : (
currentProviders.map((provider) => (
<tr key={provider.id}>
<td className="px-4 py-2">
{provider.npiNumber}
</td>
<td className="px-4 py-2">
{provider.providerName}
</td>
<td className="px-4 py-2 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingProvider(provider);
setModalOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(provider)}
>
<Delete className="h-4 w-4 text-red-600" />
</Button>
</td>
</tr>
))
currentProviders.map((provider, pageIndex) => {
const globalIndex = indexOfFirst + pageIndex;
const isDefault = globalIndex === 0;
return (
<tr key={provider.id} className={isDefault ? "bg-blue-50" : ""}>
<td className="px-4 py-2">
<span
className={`inline-flex items-center gap-1 text-sm font-medium ${
isDefault ? "text-blue-700" : "text-gray-600"
}`}
>
Provider #{globalIndex + 1}
{isDefault && (
<span className="ml-1 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
default
</span>
)}
</span>
</td>
<td className="px-4 py-2 text-sm">{provider.npiNumber}</td>
<td className="px-4 py-2 text-sm">{provider.providerName}</td>
<td className="px-4 py-2 text-right">
<Button
variant="ghost"
size="sm"
disabled={globalIndex === 0 || reorderMutation.isPending}
onClick={() => handleMove(globalIndex, "up")}
title="Move up"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={globalIndex === providers.length - 1 || reorderMutation.isPending}
onClick={() => handleMove(globalIndex, "down")}
title="Move down"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingProvider(provider);
setModalOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(provider)}
>
<Delete className="h-4 w-4 text-red-600" />
</Button>
</td>
</tr>
);
})
)}
</tbody>
</table>