feat: Users AI Chat multi-step workflows with CDT lookup and alias management
- Add eligibility_by_id and check_and_claim intents to internal chat - New cdt-lookup.ts: keyword search against fee schedule JSON (no LLM) - New internal-chat-workflow.ts: deterministic orchestration — patient resolution, insurance siteKey derivation, CDT code mapping - Custom CDT aliases stored per-user in DB (TwilioSettings JSON blob) with GET/PUT /api/ai/cdt-aliases endpoints - Chatbot UI: new steps for eligibility-id-ready, check-and-claim-ready, and need-insurance-clarification with insurance picker - Settings UI: CDT Aliases CRUD table with built-in alias reference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,10 @@ type Step =
|
||||
| "eligibility-input"
|
||||
| "eligibility-confirm"
|
||||
| "ai-loading"
|
||||
| "patient-found";
|
||||
| "patient-found"
|
||||
| "eligibility-id-ready"
|
||||
| "check-and-claim-ready"
|
||||
| "need-insurance-clarification";
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
@@ -45,6 +48,15 @@ interface EligibilityData {
|
||||
dobISO: string;
|
||||
}
|
||||
|
||||
interface CheckAndClaimData {
|
||||
patient: PatientResult | null;
|
||||
memberId: string;
|
||||
dob: string; // ISO YYYY-MM-DD
|
||||
siteKey: string;
|
||||
autoCheck: string;
|
||||
matchedCodes: { code: string; description: string }[];
|
||||
}
|
||||
|
||||
let msgCounter = 0;
|
||||
function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message {
|
||||
return { id: ++msgCounter, role, text, isLoading };
|
||||
@@ -97,6 +109,9 @@ export function ChatbotButton() {
|
||||
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
|
||||
const [freeTextInput, setFreeTextInput] = useState("");
|
||||
const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
|
||||
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null);
|
||||
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
|
||||
const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
|
||||
const [, setLocation] = useLocation();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -134,6 +149,9 @@ export function ChatbotButton() {
|
||||
setEligibilityData(null);
|
||||
setFreeTextInput("");
|
||||
setPatientResult(null);
|
||||
setEligibilityIdData(null);
|
||||
setCheckAndClaimData(null);
|
||||
setClarificationData(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -199,6 +217,37 @@ export function ChatbotButton() {
|
||||
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
|
||||
};
|
||||
|
||||
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
|
||||
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
|
||||
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
||||
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
|
||||
};
|
||||
|
||||
const handleEligibilityIdRun = () => {
|
||||
if (!eligibilityIdData) return;
|
||||
addMsg("user", "Check eligibility now");
|
||||
addMsg("bot", "Opening the eligibility check page...");
|
||||
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
|
||||
};
|
||||
|
||||
const handleCheckAndClaimRun = () => {
|
||||
if (!checkAndClaimData) return;
|
||||
addMsg("user", "Run check & claim");
|
||||
addMsg("bot", "Opening the eligibility check page...");
|
||||
// Store claim codes so the eligibility page can offer auto-claim after ACTIVE result
|
||||
sessionStorage.setItem(
|
||||
"chatbot_claim_codes",
|
||||
JSON.stringify({
|
||||
codes: checkAndClaimData.matchedCodes,
|
||||
siteKey: checkAndClaimData.siteKey,
|
||||
patientId: checkAndClaimData.patient?.id ?? null,
|
||||
memberId: checkAndClaimData.memberId,
|
||||
dob: checkAndClaimData.dob,
|
||||
})
|
||||
);
|
||||
prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck);
|
||||
};
|
||||
|
||||
const handleFreeTextSubmit = async () => {
|
||||
const text = freeTextInput.trim();
|
||||
if (!text || step === "ai-loading") return;
|
||||
@@ -227,6 +276,43 @@ export function ChatbotButton() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.action === "eligibility_id_ready" && data.actionData) {
|
||||
setEligibilityIdData({
|
||||
memberId: data.actionData.memberId,
|
||||
dob: data.actionData.dob,
|
||||
siteKey: data.actionData.siteKey,
|
||||
autoCheck: data.actionData.autoCheck,
|
||||
patient: data.actionData.patient ?? null,
|
||||
});
|
||||
setStep("eligibility-id-ready");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.action === "check_and_claim_ready" && data.actionData) {
|
||||
setCheckAndClaimData({
|
||||
patient: data.actionData.patient ?? null,
|
||||
memberId: data.actionData.memberId,
|
||||
dob: data.actionData.dob,
|
||||
siteKey: data.actionData.siteKey,
|
||||
autoCheck: data.actionData.autoCheck,
|
||||
matchedCodes: data.actionData.matchedCodes ?? [],
|
||||
});
|
||||
setStep("check-and-claim-ready");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.action === "need_insurance_clarification" && data.actionData) {
|
||||
setClarificationData({
|
||||
memberId: data.actionData.memberId,
|
||||
dob: data.actionData.dob,
|
||||
patient: data.actionData.patient ?? null,
|
||||
procedureNames: data.actionData.procedureNames ?? [],
|
||||
options: data.actionData.options ?? [],
|
||||
});
|
||||
setStep("need-insurance-clarification");
|
||||
return;
|
||||
}
|
||||
|
||||
setStep("menu");
|
||||
} catch {
|
||||
replaceLastMsg("Sorry, something went wrong. Please try again.");
|
||||
@@ -241,7 +327,13 @@ export function ChatbotButton() {
|
||||
}
|
||||
};
|
||||
|
||||
const showFreeTextInput = step === "menu" || step === "ai-loading";
|
||||
const showFreeTextInput =
|
||||
step === "menu" ||
|
||||
step === "ai-loading" ||
|
||||
step === "patient-found" ||
|
||||
step === "eligibility-id-ready" ||
|
||||
step === "check-and-claim-ready" ||
|
||||
step === "need-insurance-clarification";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -416,6 +508,129 @@ export function ChatbotButton() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Eligibility by ID ready */}
|
||||
{step === "eligibility-id-ready" && eligibilityIdData && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 space-y-2">
|
||||
{eligibilityIdData.patient && (
|
||||
<p className="text-xs font-semibold text-blue-800">
|
||||
{eligibilityIdData.patient.firstName} {eligibilityIdData.patient.lastName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-blue-600">ID: {eligibilityIdData.memberId}</p>
|
||||
<p className="text-xs text-gray-500">DOB: {eligibilityIdData.dob}</p>
|
||||
{eligibilityIdData.patient?.insuranceProvider && (
|
||||
<p className="text-xs text-gray-500">{eligibilityIdData.patient.insuranceProvider}</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90"
|
||||
onClick={handleEligibilityIdRun}
|
||||
>
|
||||
<Stethoscope className="h-3 w-3 mr-1" />
|
||||
Check Eligibility
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check & Claim ready */}
|
||||
{step === "check-and-claim-ready" && checkAndClaimData && (
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-3 space-y-2">
|
||||
{checkAndClaimData.patient && (
|
||||
<p className="text-xs font-semibold text-teal-800">
|
||||
{checkAndClaimData.patient.firstName} {checkAndClaimData.patient.lastName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-teal-600">ID: {checkAndClaimData.memberId} · DOB: {checkAndClaimData.dob}</p>
|
||||
{checkAndClaimData.matchedCodes.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium text-teal-700">Claim after ACTIVE:</p>
|
||||
{checkAndClaimData.matchedCodes.map((c) => (
|
||||
<p key={c.code} className="text-xs text-gray-600 pl-2">
|
||||
{c.code} — {c.description}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs bg-teal-600 hover:bg-teal-700 text-white"
|
||||
onClick={handleCheckAndClaimRun}
|
||||
>
|
||||
<Stethoscope className="h-3 w-3 mr-1" />
|
||||
Check & Claim
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Need insurance clarification */}
|
||||
{step === "need-insurance-clarification" && clarificationData && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-amber-800">Which insurance?</p>
|
||||
<p className="text-xs text-gray-500">ID: {clarificationData.memberId}</p>
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
{clarificationData.options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
className="text-left text-xs px-3 py-1.5 rounded-lg border border-amber-300 hover:bg-amber-100 transition-colors"
|
||||
onClick={() => {
|
||||
addMsg("user", opt);
|
||||
addMsg("bot", "Thinking...", true);
|
||||
setStep("ai-loading");
|
||||
apiRequest("POST", "/api/ai/internal-chat", {
|
||||
message: `check ${opt} for ${clarificationData.memberId}, ${clarificationData.dob}${clarificationData.procedureNames.length > 0 ? " and claim " + clarificationData.procedureNames.join(", ") : ""}`,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
||||
if (data.action === "check_and_claim_ready" && data.actionData) {
|
||||
setCheckAndClaimData({
|
||||
patient: data.actionData.patient ?? null,
|
||||
memberId: data.actionData.memberId,
|
||||
dob: data.actionData.dob,
|
||||
siteKey: data.actionData.siteKey,
|
||||
autoCheck: data.actionData.autoCheck,
|
||||
matchedCodes: data.actionData.matchedCodes ?? [],
|
||||
});
|
||||
setStep("check-and-claim-ready");
|
||||
} else if (data.action === "eligibility_id_ready" && data.actionData) {
|
||||
setEligibilityIdData({
|
||||
memberId: data.actionData.memberId,
|
||||
dob: data.actionData.dob,
|
||||
siteKey: data.actionData.siteKey,
|
||||
autoCheck: data.actionData.autoCheck,
|
||||
patient: data.actionData.patient ?? null,
|
||||
});
|
||||
setStep("eligibility-id-ready");
|
||||
} else {
|
||||
setStep("menu");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
replaceLastMsg("Sorry, something went wrong.");
|
||||
setStep("menu");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={reset}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal } from "lucide-react";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -673,23 +674,86 @@ function InternalChatSettingsCard() {
|
||||
to navigate directly to a page.
|
||||
</p>
|
||||
|
||||
{/* Capability summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||
{[
|
||||
{ icon: "🔍", label: "Patient search", desc: 'e.g. "find GONZALES"' },
|
||||
{ icon: "🏥", label: "Eligibility prefill", desc: 'e.g. "check MARIA DE LA CRUZ"' },
|
||||
{ icon: "🗺️", label: "Navigation", desc: 'e.g. "open claims", "schedule"' },
|
||||
].map((c) => (
|
||||
<div key={c.label} className="flex items-start gap-2 rounded-lg border bg-muted/30 p-3">
|
||||
<span className="text-base leading-none">{c.icon}</span>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{c.label}</p>
|
||||
<p className="text-muted-foreground mt-0.5">{c.desc}</p>
|
||||
{/* Built-in workflows */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-foreground uppercase tracking-wide">Built-in Workflows</p>
|
||||
<div className="divide-y rounded-lg border overflow-hidden text-xs">
|
||||
|
||||
{/* Eligibility by name */}
|
||||
<div className="flex items-start gap-3 p-3 bg-background">
|
||||
<span className="mt-0.5 shrink-0 text-base">🏥</span>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-foreground">Eligibility by patient name</p>
|
||||
<p className="text-muted-foreground">
|
||||
Looks up the patient in the database, resolves their insurance, and opens the eligibility page pre-filled.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{["check Maria Jesus", "verify insurance for John Smith"].map((ex) => (
|
||||
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Eligibility by member ID */}
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/20">
|
||||
<span className="mt-0.5 shrink-0 text-base">🔢</span>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-foreground">Eligibility by Member ID + DOB</p>
|
||||
<p className="text-muted-foreground">
|
||||
Provide a member ID and date of birth. Insurance is resolved from the patient record, or from what you state in the message.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{["check masshealth for 100xxxx, 10/10/1988"].map((ex) => (
|
||||
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check & Claim */}
|
||||
<div className="flex items-start gap-3 p-3 bg-background">
|
||||
<span className="mt-0.5 shrink-0 text-base">⚡</span>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-foreground">Check eligibility + claim procedures</p>
|
||||
<p className="text-muted-foreground">
|
||||
Resolves the patient, maps procedure names to CDT codes using the fee schedule, and opens the eligibility page ready to check and claim. Procedure names or CDT codes both work.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{[
|
||||
"check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning",
|
||||
"check Maria Jesus and claim D0120 D1110",
|
||||
].map((ex) => (
|
||||
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono block">{ex}</code>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground pt-1">
|
||||
CDT codes are looked up from the fee schedule — no AI translation needed.
|
||||
If insurance is unknown the assistant will ask which one to use.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/20">
|
||||
<span className="mt-0.5 shrink-0 text-base">🗺️</span>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-foreground">Navigation</p>
|
||||
<p className="text-muted-foreground">Opens any page in the app.</p>
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{["open claims", "go to schedule", "find patient GONZALES"].map((ex) => (
|
||||
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CDT Aliases */}
|
||||
<CdtAliasesCard />
|
||||
|
||||
{/* Additional context / system prompt */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -736,6 +800,207 @@ function InternalChatSettingsCard() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CDT Aliases card ────────────────────────────────────────────────────────
|
||||
|
||||
const BUILTIN_ALIASES = [
|
||||
{ phrase: "perio exam", cdtCode: "D0120" },
|
||||
{ phrase: "periodic exam", cdtCode: "D0120" },
|
||||
{ phrase: "adult cleaning", cdtCode: "D1110" },
|
||||
{ phrase: "adult prophy", cdtCode: "D1110" },
|
||||
{ phrase: "child cleaning", cdtCode: "D1120" },
|
||||
{ phrase: "full mouth xray", cdtCode: "D0210" },
|
||||
{ phrase: "fmx", cdtCode: "D0210" },
|
||||
{ phrase: "pano", cdtCode: "D0330" },
|
||||
{ phrase: "comp exam", cdtCode: "D0150" },
|
||||
{ phrase: "limited exam", cdtCode: "D0140" },
|
||||
{ phrase: "scaling root planing", cdtCode: "D4341" },
|
||||
{ phrase: "srp", cdtCode: "D4341" },
|
||||
{ phrase: "perio maintenance", cdtCode: "D4910" },
|
||||
];
|
||||
|
||||
type CdtAlias = { phrase: string; cdtCode: string };
|
||||
|
||||
function CdtAliasesCard() {
|
||||
const { toast } = useToast();
|
||||
const [aliases, setAliases] = useState<CdtAlias[]>([]);
|
||||
const [newPhrase, setNewPhrase] = useState("");
|
||||
const [newCode, setNewCode] = useState("");
|
||||
const [showBuiltin, setShowBuiltin] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const { data, isLoading } = useQuery<CdtAlias[]>({
|
||||
queryKey: ["/api/ai/cdt-aliases"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/ai/cdt-aliases");
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !initialized.current) {
|
||||
initialized.current = true;
|
||||
setAliases(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (list: CdtAlias[]) => {
|
||||
const res = await apiRequest("PUT", "/api/ai/cdt-aliases", list);
|
||||
if (!res.ok) throw new Error("Failed to save");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (saved: CdtAlias[]) => {
|
||||
setAliases(saved);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/cdt-aliases"] });
|
||||
toast({ title: "CDT aliases saved" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Error", description: "Failed to save aliases.", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
const phrase = newPhrase.trim().toLowerCase();
|
||||
const code = newCode.trim().toUpperCase();
|
||||
if (!phrase || !code) return;
|
||||
if (aliases.some((a) => a.phrase === phrase)) {
|
||||
toast({ title: "Duplicate", description: `"${phrase}" already exists.`, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const updated = [...aliases, { phrase, cdtCode: code }];
|
||||
setAliases(updated);
|
||||
saveMutation.mutate(updated);
|
||||
setNewPhrase("");
|
||||
setNewCode("");
|
||||
};
|
||||
|
||||
const handleDelete = (idx: number) => {
|
||||
const updated = aliases.filter((_, i) => i !== idx);
|
||||
setAliases(updated);
|
||||
saveMutation.mutate(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookMarked className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-base font-semibold">CDT Aliases</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Map your own shorthand phrases to CDT codes. These override the built-in aliases when
|
||||
staff type procedure names in the chat (e.g.{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded font-mono">"check & claim perio exam"</code>).
|
||||
Phrases are matched case-insensitively.
|
||||
</p>
|
||||
|
||||
{/* Custom alias list */}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : aliases.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No custom aliases yet — add one below.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Phrase (what staff type)</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground">CDT Code</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{aliases.map((a, i) => (
|
||||
<tr key={i} className="bg-background">
|
||||
<td className="px-3 py-2">
|
||||
<code className="font-mono">{a.phrase}</code>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono font-semibold text-primary">{a.cdtCode}</td>
|
||||
<td className="px-2 py-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(i)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add row */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Phrase</p>
|
||||
<Input
|
||||
value={newPhrase}
|
||||
onChange={(e) => setNewPhrase(e.target.value)}
|
||||
placeholder='e.g. "cleaning adult"'
|
||||
className="h-8 text-xs"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">CDT Code</p>
|
||||
<Input
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value)}
|
||||
placeholder="D1110"
|
||||
className="h-8 text-xs font-mono"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 shrink-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!newPhrase.trim() || !newCode.trim() || saveMutation.isPending}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Built-in aliases reference (collapsible) */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted/40 transition-colors"
|
||||
onClick={() => setShowBuiltin((v) => !v)}
|
||||
>
|
||||
<span>View built-in aliases ({BUILTIN_ALIASES.length})</span>
|
||||
{showBuiltin ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{showBuiltin && (
|
||||
<table className="w-full text-xs border-t">
|
||||
<tbody className="divide-y">
|
||||
{BUILTIN_ALIASES.map((a) => (
|
||||
<tr key={a.phrase} className="bg-muted/20">
|
||||
<td className="px-3 py-1.5">
|
||||
<code className="font-mono text-muted-foreground">{a.phrase}</code>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono font-semibold text-muted-foreground">{a.cdtCode}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground/60 italic text-[10px]">built-in</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function AiChatSettingsCard() {
|
||||
|
||||
Reference in New Issue
Block a user