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