feat: add assistant chatbot with eligibility auto-check
- Add ChatbotButton component to top-app-bar (bot icon, upper right) - Slide-in chat panel with 4 options: Check Eligibility, Schedule, Claims, Chat - Single paste area accepts member ID + DOB in either order - Age-based routing: ≥21 → MH Eligibility & History, <21 → CMSP auto-triggered - Insurance-status page prefills fields and auto-fires the correct button via sessionStorage + custom event Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
360
apps/Frontend/src/components/layout/chatbot.tsx
Normal file
360
apps/Frontend/src/components/layout/chatbot.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Bot,
|
||||
X,
|
||||
ChevronRight,
|
||||
Stethoscope,
|
||||
Calendar,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useLocation } from "wouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Step = "menu" | "eligibility-input" | "eligibility-confirm";
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
role: "bot" | "user";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface EligibilityData {
|
||||
memberId: string;
|
||||
dob: string; // MM/DD/YYYY display format
|
||||
dobISO: string; // YYYY-MM-DD for storage
|
||||
}
|
||||
|
||||
let msgCounter = 0;
|
||||
function makeMsg(role: "bot" | "user", text: string): Message {
|
||||
return { id: ++msgCounter, role, text };
|
||||
}
|
||||
|
||||
function getAutoCheck(dobISO: string): "mh" | "cmsp" {
|
||||
const [y, m, d] = dobISO.split("-").map(Number);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - (y ?? 0);
|
||||
const monthDiff = today.getMonth() + 1 - (m ?? 0);
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < (d ?? 0))) age--;
|
||||
return age >= 21 ? "mh" : "cmsp";
|
||||
}
|
||||
|
||||
function parseEligibilityInput(
|
||||
raw: string
|
||||
): { memberId: string; display: string; iso: string } | null {
|
||||
// Find a date anywhere in the text (MM/DD/YYYY or MM-DD-YYYY)
|
||||
const dateMatch = raw.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\b/);
|
||||
if (!dateMatch) return null;
|
||||
const m = dateMatch[1];
|
||||
const d = dateMatch[2];
|
||||
const y = dateMatch[3];
|
||||
if (!m || !d || !y) return null;
|
||||
const month = parseInt(m, 10);
|
||||
const day = parseInt(d, 10);
|
||||
const year = parseInt(y, 10);
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900) return null;
|
||||
|
||||
// Remove the matched date, then collect remaining alphanumeric chars as member ID
|
||||
const withoutDate = raw.replace(dateMatch[0], "");
|
||||
const memberId = (withoutDate.match(/[a-zA-Z0-9]/g) ?? []).join("");
|
||||
if (!memberId) return null;
|
||||
|
||||
return {
|
||||
memberId,
|
||||
display: `${m.padStart(2, "0")}/${d.padStart(2, "0")}/${y}`,
|
||||
iso: `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const INITIAL_MESSAGES: Message[] = [
|
||||
makeMsg("bot", "Hi! What can I help you with today?"),
|
||||
];
|
||||
|
||||
export function ChatbotButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<Step>("menu");
|
||||
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
||||
const [pasteInput, setPasteInput] = useState("");
|
||||
const [parseError, setParseError] = useState("");
|
||||
const [eligibilityData, setEligibilityData] =
|
||||
useState<EligibilityData | null>(null);
|
||||
const [, setLocation] = useLocation();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "eligibility-input") {
|
||||
setTimeout(() => pasteRef.current?.focus(), 50);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const addMsg = (role: "bot" | "user", text: string) =>
|
||||
setMessages((prev) => [...prev, makeMsg(role, text)]);
|
||||
|
||||
const reset = () => {
|
||||
setStep("menu");
|
||||
setMessages([makeMsg("bot", "Hi! What can I help you with today?")]);
|
||||
setPasteInput("");
|
||||
setParseError("");
|
||||
setEligibilityData(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleOptionSelect = (
|
||||
option: "eligibility" | "schedule" | "claims" | "chat"
|
||||
) => {
|
||||
if (option === "schedule") {
|
||||
addMsg("user", "Schedule an appointment");
|
||||
addMsg("bot", "Opening the appointments page...");
|
||||
setTimeout(() => {
|
||||
setLocation("/appointments");
|
||||
setOpen(false);
|
||||
reset();
|
||||
}, 600);
|
||||
} else if (option === "claims") {
|
||||
addMsg("user", "View claims");
|
||||
addMsg("bot", "Opening the claims page...");
|
||||
setTimeout(() => {
|
||||
setLocation("/claims");
|
||||
setOpen(false);
|
||||
reset();
|
||||
}, 600);
|
||||
} else if (option === "chat") {
|
||||
addMsg("user", "General chat");
|
||||
addMsg(
|
||||
"bot",
|
||||
"This feature is coming soon! For now, use the quick options below to navigate."
|
||||
);
|
||||
} else if (option === "eligibility") {
|
||||
addMsg("user", "Check Eligibility");
|
||||
addMsg("bot", "Please enter the patient's Member ID and Date of Birth:");
|
||||
setStep("eligibility-input");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEligibilitySubmit = () => {
|
||||
const parsed = parseEligibilityInput(pasteInput);
|
||||
if (!parsed) {
|
||||
setParseError(
|
||||
"Couldn't find both a Member ID and a date. Try: 123456789 01/15/1985"
|
||||
);
|
||||
return;
|
||||
}
|
||||
setParseError("");
|
||||
|
||||
const data: EligibilityData = {
|
||||
memberId: parsed.memberId,
|
||||
dob: parsed.display,
|
||||
dobISO: parsed.iso,
|
||||
};
|
||||
setEligibilityData(data);
|
||||
|
||||
addMsg("user", pasteInput.trim());
|
||||
addMsg(
|
||||
"bot",
|
||||
`Ready to check MassHealth eligibility for:\n• Member ID: ${parsed.memberId}\n• Date of Birth: ${parsed.display}\n\nShall I proceed?`
|
||||
);
|
||||
setStep("eligibility-confirm");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!eligibilityData) return;
|
||||
addMsg("user", "Yes, check now");
|
||||
addMsg("bot", "Opening the eligibility check page with this patient...");
|
||||
sessionStorage.setItem(
|
||||
"chatbot_eligibility",
|
||||
JSON.stringify({
|
||||
memberId: eligibilityData.memberId,
|
||||
dob: eligibilityData.dobISO,
|
||||
autoCheck: getAutoCheck(eligibilityData.dobISO),
|
||||
})
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
||||
setTimeout(() => {
|
||||
setLocation("/insurance-status");
|
||||
setOpen(false);
|
||||
reset();
|
||||
}, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-8 w-8 rounded-full p-0"
|
||||
title="Open Assistant"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={handleClose}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Chat panel */}
|
||||
<div className="fixed top-16 right-0 z-50 w-80 h-[calc(100vh-4rem)] bg-white border-l shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-primary text-white shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="font-semibold text-sm">Assistant</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="hover:opacity-70 transition-opacity rounded"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages + controls */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex",
|
||||
msg.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-line leading-relaxed",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-white rounded-tr-sm"
|
||||
: "bg-gray-100 text-gray-800 rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Step: menu */}
|
||||
{step === "menu" && (
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => handleOptionSelect("eligibility")}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-primary/30 hover:bg-primary/5 text-sm text-left transition-colors"
|
||||
>
|
||||
<Stethoscope className="h-4 w-4 text-primary shrink-0" />
|
||||
<span>Check Eligibility</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOptionSelect("schedule")}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-blue-300 hover:bg-blue-50 text-sm text-left transition-colors"
|
||||
>
|
||||
<Calendar className="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<span>Schedule</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOptionSelect("claims")}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-orange-300 hover:bg-orange-50 text-sm text-left transition-colors"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-orange-500 shrink-0" />
|
||||
<span>Claims</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOptionSelect("chat")}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-gray-300 hover:bg-gray-50 text-sm text-left transition-colors"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span>Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: eligibility input */}
|
||||
{step === "eligibility-input" && (
|
||||
<div className="space-y-2 bg-gray-50 rounded-xl p-3 border">
|
||||
<Label className="text-xs text-gray-600">
|
||||
Paste Member ID and Date of Birth
|
||||
</Label>
|
||||
<textarea
|
||||
ref={pasteRef}
|
||||
rows={3}
|
||||
placeholder={"e.g. 123456789 01/15/1985\nor 01/15/1985 123456789"}
|
||||
value={pasteInput}
|
||||
onChange={(e) => {
|
||||
setPasteInput(e.target.value);
|
||||
setParseError("");
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-md border bg-white px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
parseError && "border-red-400"
|
||||
)}
|
||||
/>
|
||||
{parseError && (
|
||||
<p className="text-xs text-red-500">{parseError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={handleEligibilitySubmit}
|
||||
disabled={!pasteInput.trim()}
|
||||
>
|
||||
Continue <ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs"
|
||||
onClick={reset}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: confirm */}
|
||||
{step === "eligibility-confirm" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Yes, Check Now
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => {
|
||||
setStep("eligibility-input");
|
||||
setMessages((prev) => prev.slice(0, -2));
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { NotificationsBell } from "@/components/layout/notification-bell";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { ChatbotButton } from "@/components/layout/chatbot";
|
||||
|
||||
export function TopAppBar() {
|
||||
const { user, logoutMutation } = useAuth();
|
||||
@@ -53,6 +54,7 @@ export function TopAppBar() {
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<NotificationsBell />
|
||||
<ChatbotButton />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -114,6 +114,27 @@ export default function InsuranceStatusPage() {
|
||||
const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState<number | null>(null);
|
||||
const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState<string | null>(null);
|
||||
|
||||
const pendingAutoCheck = useRef<"mh" | "cmsp" | null>(null);
|
||||
|
||||
// Prefill from chatbot
|
||||
useEffect(() => {
|
||||
const apply = () => {
|
||||
const raw = sessionStorage.getItem("chatbot_eligibility");
|
||||
if (!raw) return;
|
||||
try {
|
||||
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
|
||||
if (id) setMemberId(id);
|
||||
if (dob) setDateOfBirth(parseLocalDate(dob));
|
||||
if (ac === "mh" || ac === "cmsp") pendingAutoCheck.current = ac;
|
||||
sessionStorage.removeItem("chatbot_eligibility");
|
||||
} catch {}
|
||||
};
|
||||
apply();
|
||||
window.addEventListener("chatbot:eligibility-prefill", apply);
|
||||
return () =>
|
||||
window.removeEventListener("chatbot:eligibility-prefill", apply);
|
||||
}, []);
|
||||
|
||||
// Populate fields from selected patient
|
||||
useEffect(() => {
|
||||
if (selectedPatient) {
|
||||
@@ -125,7 +146,7 @@ export default function InsuranceStatusPage() {
|
||||
typeof selectedPatient.dateOfBirth === "string"
|
||||
? parseLocalDate(selectedPatient.dateOfBirth)
|
||||
: selectedPatient.dateOfBirth;
|
||||
setDateOfBirth(dob);
|
||||
setDateOfBirth(dob ?? null);
|
||||
} else {
|
||||
setMemberId("");
|
||||
setFirstName("");
|
||||
@@ -574,6 +595,19 @@ export default function InsuranceStatusPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-trigger from chatbot after prefill
|
||||
useEffect(() => {
|
||||
if (!pendingAutoCheck.current || !memberId || !dateOfBirth) return;
|
||||
const check = pendingAutoCheck.current;
|
||||
pendingAutoCheck.current = null;
|
||||
if (check === "mh") {
|
||||
handleMHEligibilityHistoryButton();
|
||||
} else {
|
||||
handleCMSPButton();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [memberId, dateOfBirth]);
|
||||
|
||||
// small helper: remove given query params from the current URL (silent, no reload)
|
||||
const clearUrlParams = (params: string[]) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user