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:
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user