diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index e5fe65dd..2bfffc33 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -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": "", "dob": "", "insuranceHint": "", + "renderingProvider": "", "procedureNames": ["", ...], "appointmentDate": "", "appointmentTime": "", @@ -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: diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index dc10d70c..6119c690 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -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, }, }; } diff --git a/apps/Backend/src/routes/npiProviders.ts b/apps/Backend/src/routes/npiProviders.ts index 60f5cf1a..402d7cdd 100755 --- a/apps/Backend/src/routes/npiProviders.ts +++ b/apps/Backend/src/routes/npiProviders.ts @@ -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) { diff --git a/apps/Backend/src/storage/npi-providers-storage.ts b/apps/Backend/src/storage/npi-providers-storage.ts index f84610e5..126285bc 100755 --- a/apps/Backend/src/storage/npi-providers-storage.ts +++ b/apps/Backend/src/storage/npi-providers-storage.ts @@ -10,6 +10,7 @@ export interface INpiProviderStorage { updates: Partial, ): Promise; deleteNpiProvider(userId: number, id: number): Promise; + reorderNpiProviders(userId: number, orderedIds: number[]): Promise; } 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 }, + }) + ) + ); + }, }; diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 741118a4..fe4ee661 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -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(() => { + 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(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(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, ]); diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index 4b887399..98d314e9 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -155,6 +155,7 @@ export function ChatbotButton() { siteKey: string; serviceDate: string; appointmentId: number | null; + renderingProvider: string | null; } | null>(null); const [pendingFiles, setPendingFiles] = useState([]); 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}`; diff --git a/apps/Frontend/src/components/settings/npiProviderTable.tsx b/apps/Frontend/src/components/settings/npiProviderTable.tsx index 64a69013..8beb3af8 100755 --- a/apps/Frontend/src/components/settings/npiProviderTable.tsx +++ b/apps/Frontend/src/components/settings/npiProviderTable.tsx @@ -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(null); - + const [editingProvider, setEditingProvider] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [providerToDelete, setProviderToDelete] = - useState(null); + const [providerToDelete, setProviderToDelete] = useState(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 (
-

- NPI Providers -

+

NPI Providers