feat: implement Claim for Column with AI queue logic

Sequential AI-assisted claim flow:
- Filters unclaimed appointments in selected columns
- Calls /api/ai/internal-chat to interpret appointment notes into CDT codes
- Shows per-appointment confirmation modal (patient name, notes, matched codes)
- Confirm → stores chatbot_claim_prefill + navigates to claims page for Selenium
- Skip → moves to next appointment
- Queue persisted in sessionStorage; auto-resumes on return to schedule page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-10 22:00:46 -04:00
parent b986350edd
commit 46daeb1c1f

View File

@@ -167,6 +167,20 @@ export default function AppointmentsPage() {
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
const [selectedClaimAiColumns, setSelectedClaimAiColumns] = useState<Set<number>>(new Set());
const [isClaimingAiColumn, setIsClaimingAiColumn] = useState(false);
const [aiClaimModalOpen, setAiClaimModalOpen] = useState(false);
const [aiClaimQueue, setAiClaimQueue] = useState<ScheduledAppointment[]>([]);
const [aiClaimCurrentIndex, setAiClaimCurrentIndex] = useState(0);
const [isAiClaimProcessing, setIsAiClaimProcessing] = useState(false);
const [aiClaimCurrentData, setAiClaimCurrentData] = useState<{
matchedCodes: Array<{ code: string; description: string; toothNumber?: string; toothSurface?: string }>;
siteKey: string;
serviceDate: string;
appointmentId: number;
patientName: string;
notes: string;
reply: string;
} | null>(null);
const [needsAiClaimResume, setNeedsAiClaimResume] = useState(false);
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true);
@@ -404,6 +418,40 @@ export default function AppointmentsPage() {
}
}, [location]);
// On mount: detect if we should resume an AI claim queue after returning from claims page
useEffect(() => {
try {
const raw = sessionStorage.getItem("ai_claim_queue");
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed?.pendingResume && Array.isArray(parsed.appointments) && parsed.appointments.length > 0) {
setNeedsAiClaimResume(true);
}
} catch {
sessionStorage.removeItem("ai_claim_queue");
}
}, []);
// When appointments finish loading and a resume is pending, reopen the modal
useEffect(() => {
if (!needsAiClaimResume || isLoadingAppointments) return;
setNeedsAiClaimResume(false);
try {
const raw = sessionStorage.getItem("ai_claim_queue");
if (!raw) return;
const parsed = JSON.parse(raw);
const queue = parsed.appointments as ScheduledAppointment[];
if (!queue?.length) { sessionStorage.removeItem("ai_claim_queue"); return; }
sessionStorage.setItem("ai_claim_queue", JSON.stringify({ ...parsed, pendingResume: false }));
setAiClaimQueue(queue);
setAiClaimCurrentIndex(0);
setAiClaimModalOpen(true);
processAiClaimAtIndex(queue, 0);
} catch {
sessionStorage.removeItem("ai_claim_queue");
}
}, [needsAiClaimResume, isLoadingAppointments]);
// Create/upsert appointment mutation
const createAppointmentMutation = useMutation({
mutationFn: async (appointment: InsertAppointment) => {
@@ -1323,6 +1371,124 @@ export default function AppointmentsPage() {
}
};
// ── AI Claim Queue ─────────────────────────────────────────────────────────
const processAiClaimAtIndex = async (queue: ScheduledAppointment[], index: number) => {
if (index >= queue.length) {
setAiClaimModalOpen(false);
sessionStorage.removeItem("ai_claim_queue");
toast({ title: "All Done", description: `Finished processing all ${queue.length} appointment${queue.length !== 1 ? "s" : ""}.` });
return;
}
const apt = queue[index]!;
const aptDate = typeof apt.date === "string" ? apt.date : formatLocalDate(apt.date as Date);
if (!apt.notes?.trim()) {
setIsAiClaimProcessing(false);
setAiClaimCurrentData({
matchedCodes: [],
siteKey: "",
serviceDate: aptDate,
appointmentId: Number(apt.id),
patientName: apt.patientName,
notes: "",
reply: "No notes on this appointment.",
});
return;
}
setIsAiClaimProcessing(true);
setAiClaimCurrentData(null);
try {
const res = await apiRequest("POST", "/api/ai/internal-chat", {
message: `claim ${apt.notes} for ${apt.patientName}`,
clientDate: aptDate,
});
const data = await res.json();
if ((data.action === "claim_only_ready" || data.action === "check_and_claim_ready") && data.actionData) {
setAiClaimCurrentData({
matchedCodes: data.actionData.matchedCodes ?? [],
siteKey: data.actionData.siteKey ?? "",
serviceDate: data.actionData.serviceDate ?? aptDate,
appointmentId: Number(apt.id),
patientName: apt.patientName,
notes: apt.notes ?? "",
reply: data.reply ?? "",
});
} else {
setAiClaimCurrentData({
matchedCodes: [],
siteKey: "",
serviceDate: aptDate,
appointmentId: Number(apt.id),
patientName: apt.patientName,
notes: apt.notes ?? "",
reply: data.reply ?? "Could not interpret notes.",
});
}
} catch {
setAiClaimCurrentData({
matchedCodes: [],
siteKey: "",
serviceDate: aptDate,
appointmentId: Number(apt.id),
patientName: apt.patientName,
notes: apt.notes ?? "",
reply: "Error contacting AI.",
});
} finally {
setIsAiClaimProcessing(false);
}
};
const handleClaimForColumnWithAi = async () => {
if (selectedClaimAiColumns.size === 0) return;
const unclaimed = processedAppointments.filter(
(apt) => selectedClaimAiColumns.has(apt.staffId) && !apt.hasClaimWithNumber
);
if (!unclaimed.length) {
toast({ title: "No unclaimed appointments", description: "All appointments in the selected columns are already claimed." });
return;
}
sessionStorage.setItem("ai_claim_queue", JSON.stringify({
appointments: unclaimed,
date: formattedSelectedDate,
pendingResume: false,
}));
setAiClaimQueue(unclaimed);
setAiClaimCurrentIndex(0);
setAiClaimModalOpen(true);
await processAiClaimAtIndex(unclaimed, 0);
};
const handleAiClaimConfirm = () => {
if (!aiClaimCurrentData) return;
const { matchedCodes, siteKey, serviceDate, appointmentId } = aiClaimCurrentData;
const nextIndex = aiClaimCurrentIndex + 1;
const remaining = aiClaimQueue.slice(nextIndex);
if (remaining.length > 0) {
sessionStorage.setItem("ai_claim_queue", JSON.stringify({
appointments: remaining,
date: formattedSelectedDate,
pendingResume: true,
}));
} else {
sessionStorage.removeItem("ai_claim_queue");
}
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({
codes: matchedCodes,
siteKey,
serviceDate,
autoSubmit: true,
}));
setAiClaimModalOpen(false);
setLocation(`/claims?appointmentId=${appointmentId}`);
};
const handleAiClaimSkip = async () => {
const nextIndex = aiClaimCurrentIndex + 1;
setAiClaimCurrentIndex(nextIndex);
await processAiClaimAtIndex(aiClaimQueue, nextIndex);
};
return (
<div>
<SeleniumTaskBanner
@@ -1438,7 +1604,7 @@ export default function AppointmentsPage() {
{/* Claim for Column with AI section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => {/* logic TBD */}}
onClick={() => handleClaimForColumnWithAi()}
disabled={isLoading || isClaimingAiColumn || selectedClaimAiColumns.size === 0}
size="sm"
>
@@ -2007,6 +2173,84 @@ export default function AppointmentsPage() {
onHandleForTuftsSCOSeleniumClaim={() => {}}
/>
)}
{/* AI Claim Queue Modal */}
{aiClaimModalOpen && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="font-semibold text-base flex items-center gap-2">
<Bot className="h-4 w-4 text-teal-600" />
AI Claim Queue
</h3>
<span className="text-xs text-gray-500 font-medium">
{aiClaimCurrentIndex + 1} / {aiClaimQueue.length}
</span>
</div>
<div className="p-4">
{isAiClaimProcessing ? (
<div className="flex items-center gap-2 text-sm text-gray-500 py-4">
<LoaderCircleIcon className="h-4 w-4 animate-spin text-teal-600" />
Interpreting notes with AI...
</div>
) : aiClaimCurrentData ? (
<div className="space-y-3">
<div>
<p className="text-sm font-semibold">{aiClaimCurrentData.patientName}</p>
{aiClaimCurrentData.notes && (
<p className="text-xs text-gray-500 mt-0.5">
Notes: <span className="italic">{aiClaimCurrentData.notes}</span>
</p>
)}
</div>
<p className="text-xs text-gray-600">{aiClaimCurrentData.reply}</p>
{aiClaimCurrentData.matchedCodes.length > 0 ? (
<div className="bg-teal-50 border border-teal-200 rounded p-2 space-y-1">
{aiClaimCurrentData.matchedCodes.map((c) => (
<p key={c.code} className="text-xs">
<span className="font-semibold text-teal-800">{c.code}</span>
<span className="text-gray-600"> {c.description}</span>
{c.toothNumber && <span className="text-gray-500"> (#{c.toothNumber})</span>}
</p>
))}
</div>
) : (
<p className="text-xs text-amber-600 bg-amber-50 rounded p-2">
No procedures could be matched from notes. Skip this appointment or cancel.
</p>
)}
<div className="flex gap-2 pt-1">
{aiClaimCurrentData.matchedCodes.length > 0 && (
<button
className="flex-1 flex items-center justify-center gap-1 text-xs h-8 px-3 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium transition-colors"
onClick={handleAiClaimConfirm}
>
<FileCheck className="h-3.5 w-3.5" />
Confirm &amp; Claim
</button>
)}
<button
className="flex items-center justify-center gap-1 text-xs h-8 px-3 rounded border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium transition-colors"
onClick={handleAiClaimSkip}
>
Skip
</button>
<button
className="flex items-center justify-center gap-1 text-xs h-8 px-3 rounded hover:bg-gray-100 text-gray-500 transition-colors"
onClick={() => {
setAiClaimModalOpen(false);
sessionStorage.removeItem("ai_claim_queue");
}}
>
Cancel All
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
)}
</div>
);
}