import { useState, useEffect, useRef, useCallback } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { addDays, addWeeks, startOfToday, addMinutes } from "date-fns"; import { parseLocalDate, formatLocalDate, formatLocalTime, formatDateToHumanReadable, } from "@/utils/dateUtils"; import { AddAppointmentModal } from "@/components/appointments/add-appointment-modal"; import type { NewAppointmentPrefill } from "@/components/appointments/appointment-form"; import { Button } from "@/components/ui/button"; import { Calendar as CalendarIcon, Plus, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Move, Trash2, CreditCard, ClipboardList, StickyNote, Shield, FileCheck, LoaderCircleIcon, Stethoscope, Download, MessageSquare, Clock, ExternalLink, Bot, UserCheck, } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { Calendar } from "@/components/ui/calendar"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { useAuth } from "@/hooks/use-auth"; import { DndProvider, useDrag, useDrop } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { Menu, Item, useContextMenu } from "react-contexify"; import "react-contexify/ReactContexify.css"; import { useLocation } from "wouter"; import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog"; import { Appointment, InsertAppointment, Patient, PatientStatus, UpdateAppointment, UpdatePatient, } from "@repo/db/types"; import { ClaimForm } from "@/components/claims/claim-form"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useAppDispatch, useAppSelector } from "@/redux/hooks"; import { clearTaskStatus, setTaskStatus, } from "@/redux/slices/seleniumTaskSlice"; import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner"; import { PatientStatusBadge } from "@/components/appointments/patient-status-badge"; import type { OfficeHoursData } from "@/components/settings/office-hours-card"; import { MessageThread } from "@/components/patient-connection/message-thread"; import { getAppointmentTypeLabel } from "@/utils/appointmentTypeUtils"; // Define types for scheduling interface TimeSlot { time: string; displayTime: string; } interface Staff { id: string; name: string; role: "doctor" | "hygienist"; color: string; } interface ScheduledAppointment { id?: number; patientId: number; patientName: string; eligibilityStatus: PatientStatus; patientStatus: PatientStatus; patientInsuranceProvider: string | null; hasProcedures: boolean; hasClaimWithNumber: boolean; movedByAi?: boolean; staffId: number; date: string | Date; startTime: string | Date; endTime: string | Date; status: string | null; type: string; notes?: string | null; procedureCodes?: string[]; } function appointmentCardColor(apt: ScheduledAppointment): string { if (apt.hasClaimWithNumber) return "bg-gray-500 text-white"; if (apt.hasProcedures) return "bg-blue-500 text-white"; return "bg-slate-100 text-gray-700 border-slate-300"; } function resolveAppointmentBadgeStatus(apt: ScheduledAppointment): PatientStatus { if (apt.eligibilityStatus === "ACTIVE") return "ACTIVE"; if (apt.eligibilityStatus === "INACTIVE") return "INACTIVE"; if (apt.eligibilityStatus === "UNKNOWN") { if (apt.patientStatus === "ACTIVE") return "ACTIVE"; if (apt.patientStatus === "INACTIVE") return "INACTIVE"; } return apt.eligibilityStatus ?? "UNKNOWN"; } // Define a unique ID for the appointment context menu const APPOINTMENT_CONTEXT_MENU_ID = "appointment-context-menu"; // ๐Ÿ”‘ exported base key export const QK_APPOINTMENTS_BASE = ["appointments", "day"] as const; // helper (optional) โ€“ mirrors the query key structure export const qkAppointmentsDay = (date: string) => [...QK_APPOINTMENTS_BASE, date] as const; export default function AppointmentsPage() { const { toast } = useToast(); const { user } = useAuth(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [calendarOpen, setCalendarOpen] = useState(false); const [editingAppointment, setEditingAppointment] = useState< Appointment | undefined >(undefined); const [selectedDate, setSelectedDate] = useState(startOfToday()); const [location] = useLocation(); const [confirmDeleteState, setConfirmDeleteState] = useState<{ open: boolean; appointmentId?: number; }>({ open: false }); const dispatch = useAppDispatch(); const batchTask = useAppSelector( (state) => state.seleniumTasks.eligibilityBatchCheck ); const claimBatchTask = useAppSelector( (state) => state.seleniumTasks.claimBatchCheck ); const [isCheckingAllElig, setIsCheckingAllElig] = useState(false); const [processedAppointmentIds, setProcessedAppointmentIds] = useState< Record >({}); const [selectedStaffColumns, setSelectedStaffColumns] = useState>(new Set()); const toggleStaffColumn = (staffId: number) => { setSelectedStaffColumns((prev) => { const next = new Set(prev); if (next.has(staffId)) next.delete(staffId); else next.add(staffId); return next; }); }; const [selectedClaimColumns, setSelectedClaimColumns] = useState>(new Set()); const [isClaimingColumn, setIsClaimingColumn] = useState(false); const [selectedClaimAiColumns, setSelectedClaimAiColumns] = useState>(new Set()); const [isClaimingAiColumn, setIsClaimingAiColumn] = useState(false); const [autoClaimEnabled, setAutoClaimEnabled] = useState(false); const [autoClaimTime, setAutoClaimTime] = useState("19:00"); const autoClaimFiredHourRef = useRef(null); const [aiClaimModalOpen, setAiClaimModalOpen] = useState(false); const [aiClaimQueue, setAiClaimQueue] = useState([]); 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 [aiClaimCdtClarification, setAiClaimCdtClarification] = useState<{ unknownPhrases: string[]; codeInputs: Record; originalMessage: string; aptDate: string; appointmentId: number; patientName: string; notes: string; matchedSoFar: Array<{ code: string; description: string }>; } | null>(null); const [selectedReminderColumns, setSelectedReminderColumns] = useState>(new Set()); const [isSendingReminders, setIsSendingReminders] = useState(false); const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true); const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState>(new Set()); const [isSendingReschedule, setIsSendingReschedule] = useState(false); const [rescheduleAiFollowUp, setRescheduleAiFollowUp] = useState(true); const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState>(new Set()); const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false); const [columnLabels, setColumnLabels] = useState>({}); const [editingLabelStaffId, setEditingLabelStaffId] = useState(null); const [pendingOverride, setPendingOverride] = useState<{ type: "move" | "create"; appointmentId?: number; timeSlot: TimeSlot; staffId: number; } | null>(null); const [newApptPrefill, setNewApptPrefill] = useState(null); const toggleReminderColumn = (staffId: number) => { setSelectedReminderColumns((prev) => { const next = new Set(prev); if (next.has(staffId)) next.delete(staffId); else next.add(staffId); return next; }); }; const toggleClaimColumn = (staffId: number) => { setSelectedClaimColumns((prev) => { const next = new Set(prev); if (next.has(staffId)) next.delete(staffId); else next.add(staffId); return next; }); }; const toggleClaimAiColumn = (staffId: number) => { setSelectedClaimAiColumns((prev) => { const next = new Set(prev); if (next.has(staffId)) next.delete(staffId); else next.add(staffId); return next; }); }; const toggleRescheduleColumn = (staffId: number) => { setSelectedRescheduleColumns((prev) => { const next = new Set(prev); if (next.has(staffId)) next.delete(staffId); else next.add(staffId); return next; }); }; const toggleDownloadPdfColumn = (staffId: number) => { setSelectedDownloadPdfColumns((prev) => { const next = new Set(prev); if (next.has(staffId)) next.delete(staffId); else next.add(staffId); return next; }); }; const [, setLocation] = useLocation(); // Select Procedures modal state (opened inline, no navigation) const [isSelectProceduresOpen, setIsSelectProceduresOpen] = useState(false); const [selectProceduresPatientId, setSelectProceduresPatientId] = useState(null); const [selectProceduresAppointmentId, setSelectProceduresAppointmentId] = useState(null); // Chat popup state const [chatPatient, setChatPatient] = useState(null); const [chatAppointmentInfo, setChatAppointmentInfo] = useState<{ date: string; startTime: string } | undefined>(undefined); // Create context menu hook const { show } = useContextMenu({ id: APPOINTMENT_CONTEXT_MENU_ID, }); // ---------------------- // Day-level fetch: appointments + patients for selectedDate (lightweight) // ---------------------- const formattedSelectedDate = formatLocalDate(selectedDate); type DayPayload = { appointments: Appointment[]; patients: Patient[] }; const { data: dayPayload = { appointments: [] as Appointment[], patients: [] as Patient[], }, isLoading: isLoadingAppointments, refetch: refetchAppointments, } = useQuery< DayPayload, Error, DayPayload, readonly [string, string, string] >({ queryKey: qkAppointmentsDay(formattedSelectedDate), queryFn: async () => { const res = await apiRequest( "GET", `/api/appointments/day?date=${formattedSelectedDate}` ); if (!res.ok) { throw new Error("Failed to load appointments for date"); } return res.json(); }, enabled: !!user && !!formattedSelectedDate, // placeholderData: keepPreviousData, }); const appointments = dayPayload.appointments ?? []; const patientsFromDay = dayPayload.patients ?? []; // Office hours (used to enforce scheduling rules) const { data: officeHours } = useQuery({ queryKey: ["/api/office-hours"], queryFn: async () => { const res = await apiRequest("GET", "/api/office-hours"); if (!res.ok) return null; return res.json(); }, enabled: !!user, }); // Staff memebers const { data: staffMembersRaw = [] as Staff[] } = useQuery({ queryKey: ["/api/staffs/"], queryFn: async () => { const res = await apiRequest("GET", "/api/staffs/"); return res.json(); }, enabled: !!user, }); const colors = [ "bg-sky-500", // light blue "bg-teal-500", // teal "bg-indigo-500", // soft indigo "bg-rose-400", // muted rose "bg-amber-400", // muted amber "bg-orange-500", // softer warm orange ]; // Assign colors cycling through the list const staffMembers = staffMembersRaw.map((staff, index) => ({ ...staff, color: colors[index % colors.length] || "bg-gray-400", })); // Initialize column labels from localStorage; default A, B, Cโ€ฆ for new staff useEffect(() => { if (!staffMembersRaw.length) return; const stored = JSON.parse( localStorage.getItem("scheduleColumnLabels") || "{}" ) as Record; let changed = false; staffMembersRaw.forEach((staff, index) => { if (!(String(staff.id) in stored)) { stored[String(staff.id)] = String.fromCharCode(65 + index); changed = true; } }); if (changed) localStorage.setItem("scheduleColumnLabels", JSON.stringify(stored)); setColumnLabels(stored); }, [staffMembersRaw]); const handleLabelSave = (staffId: string, value: string) => { const trimmed = value.trim() || String.fromCharCode(65 + staffMembersRaw.findIndex((s) => String(s.id) === staffId)); setColumnLabels((prev) => { const updated = { ...prev, [staffId]: trimmed }; localStorage.setItem("scheduleColumnLabels", JSON.stringify(updated)); return updated; }); setEditingLabelStaffId(null); }; // Returns true when a time slot is within the configured office hours for that staff group const isWithinOfficeHours = (timeStr: string, staffIndex: number): boolean => { if (!officeHours) return true; // no config = unrestricted // If today is an override date, all slots are open const todayYMD = selectedDate.toLocaleDateString("en-CA"); if (officeHours.overrideDates?.includes(todayYMD)) return true; const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const; const dayName = dayNames[selectedDate.getDay()] as keyof typeof officeHours.doctors; const group = staffIndex <= 2 ? officeHours.doctors : officeHours.hygienists; const dayHours = group[dayName]; if (!dayHours || !dayHours.enabled) return false; return ( (timeStr >= dayHours.amStart && timeStr < dayHours.amEnd) || (timeStr >= dayHours.pmStart && timeStr < dayHours.pmEnd) ); }; // Generate time slots from 8:00 AM to 9:00 PM in 15-minute increments const timeSlots: TimeSlot[] = []; for (let hour = 8; hour <= 21; hour++) { for (let minute = 0; minute < 60; minute += 15) { const pad = (n: number) => n.toString().padStart(2, "0"); const timeStr = `${pad(hour)}:${pad(minute)}`; // Only allow start times up to 21:00 (9 PM) if (timeStr > "21:00") continue; const hour12 = hour > 12 ? hour - 12 : hour; const period = hour >= 12 ? "PM" : "AM"; const displayTime = `${hour12}:${pad(minute)} ${period}`; timeSlots.push({ time: timeStr, displayTime }); } } // Check for newPatient parameter in URL useEffect(() => { const params = new URLSearchParams(window.location.search); const newPatientId = params.get("newPatient"); if (newPatientId) { const patientId = parseInt(newPatientId); const firstStaff = staffMembers.length > 0 ? staffMembers[0] : undefined; const staffId = firstStaff ? Number(firstStaff.id) : 1; const defaultTimeSlot = timeSlots.find((slot) => slot.time === "09:00") || timeSlots[0]; if (!defaultTimeSlot) { toast({ title: "Unable to schedule", description: "No available time slots.", variant: "destructive" }); return; } handleCreateAppointmentAtSlot(defaultTimeSlot, staffId, patientId); params.delete("newPatient"); const newSearch = params.toString(); const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ""}${window.location.hash || ""}`; window.history.replaceState({}, "", newUrl); } }, [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]); // Auto-claim: fire handleClaimForColumnWithAi at the configured hour each day useEffect(() => { if (!autoClaimEnabled || !autoClaimTime) return; const [targetHour] = autoClaimTime.split(":").map(Number); const interval = setInterval(() => { const now = new Date(); const currentHour = now.getHours(); const currentMinute = now.getMinutes(); if (currentHour === targetHour && currentMinute === 0) { if (autoClaimFiredHourRef.current !== currentHour) { autoClaimFiredHourRef.current = currentHour; handleClaimForColumnWithAi(); } } else if (autoClaimFiredHourRef.current === currentHour && currentMinute !== 0) { autoClaimFiredHourRef.current = null; } }, 30_000); return () => clearInterval(interval); }, [autoClaimEnabled, autoClaimTime, selectedClaimAiColumns]); // Create/upsert appointment mutation const createAppointmentMutation = useMutation({ mutationFn: async (appointment: InsertAppointment) => { const res = await apiRequest( "POST", "/api/appointments/upsert", appointment ); const body = await res.json(); if (!res.ok) throw new Error(body?.message || "Failed to create appointment"); return body; }, onSuccess: (appointment) => { toast({ title: "Appointment Scheduled", description: appointment.message || "Appointment created successfully.", }); // Invalidate both appointments and patients queries queryClient.invalidateQueries({ queryKey: qkAppointmentsDay(formattedSelectedDate), }); setIsAddModalOpen(false); }, onError: (error: Error) => { toast({ title: "Error", description: `Failed to create appointment: ${error.message}`, variant: "destructive", }); }, }); // Update appointment mutation const updateAppointmentMutation = useMutation({ mutationFn: async ({ id, appointment, }: { id: number; appointment: UpdateAppointment; }) => { const res = await apiRequest( "PUT", `/api/appointments/${id}`, appointment ); return await res.json(); }, onSuccess: (appointment) => { toast({ title: "Appointment Scheduled", description: appointment.message || "Appointment updated successfully.", }); queryClient.invalidateQueries({ queryKey: qkAppointmentsDay(formattedSelectedDate), }); setEditingAppointment(undefined); setIsAddModalOpen(false); }, onError: (error: Error) => { toast({ title: "Error", description: `Failed to update appointment: ${error.message}`, variant: "destructive", }); }, }); // Delete appointment mutation const deleteAppointmentMutation = useMutation({ mutationFn: async (id: number) => { await apiRequest("DELETE", `/api/appointments/${id}`); }, onSuccess: () => { toast({ title: "Success", description: "Appointment deleted successfully.", }); // Invalidate both appointments and patients queries queryClient.invalidateQueries({ queryKey: qkAppointmentsDay(formattedSelectedDate), }); setConfirmDeleteState({ open: false }); }, onError: (error: Error) => { toast({ title: "Error", description: `Failed to delete appointment: ${error.message}`, variant: "destructive", }); }, }); // Handle appointment submission (create or update) const handleAppointmentSubmit = ( appointmentData: InsertAppointment | UpdateAppointment ) => { // Converts local date to exact UTC date with no offset issues const rawDate = parseLocalDate(appointmentData.date); const updatedData = { ...appointmentData, date: formatLocalDate(rawDate), }; // Check if we're editing an existing appointment with a valid ID if ( editingAppointment && "id" in editingAppointment && typeof editingAppointment.id === "number" ) { updateAppointmentMutation.mutate({ id: editingAppointment.id, appointment: updatedData as unknown as UpdateAppointment, }); } else { // This is a new appointment if (user) { createAppointmentMutation.mutate({ ...(updatedData as unknown as InsertAppointment), userId: user.id, }); } } }; // Handle edit appointment const handleEditAppointment = (appointment: Appointment) => { setEditingAppointment(appointment); setIsAddModalOpen(true); }; // When user confirms delete in dialog const handleConfirmDelete = () => { if (!confirmDeleteState.appointmentId) return; deleteAppointmentMutation.mutate(confirmDeleteState.appointmentId); }; const handleDeleteAppointment = (id: number) => { setConfirmDeleteState({ open: true, appointmentId: id, }); }; // Process appointments for the scheduler view const processedAppointments: ScheduledAppointment[] = ( appointments ?? [] ).map((apt) => { // Find patient name const patient = patientsFromDay.find((p) => p.id === apt.patientId); const patientName = patient ? `${patient.firstName} ${patient.lastName}` : "Unknown Patient"; const eligibilityStatus = (apt as any).eligibilityStatus as PatientStatus; const patientStatus = (patient as any)?.status as PatientStatus ?? "UNKNOWN"; const patientInsuranceProvider = (patient as any)?.insuranceProvider as string | null ?? null; const staffId = Number(apt.staffId ?? 1); const normalizedStart = typeof apt.startTime === "string" ? apt.startTime.substring(0, 5) : formatLocalTime(apt.startTime); const normalizedEnd = typeof apt.endTime === "string" ? apt.endTime.substring(0, 5) : formatLocalTime(apt.endTime); const processed = { ...apt, patientName, eligibilityStatus, patientStatus, patientInsuranceProvider, hasProcedures: !!(apt as any).hasProcedures, hasClaimWithNumber: !!(apt as any).hasClaimWithNumber, notes: (apt as any).notes ?? null, procedureCodes: (apt as any).procedureCodes ?? [], movedByAi: !!(apt as any).movedByAi, staffId, status: apt.status ?? null, date: formatLocalDate(apt.date), startTime: normalizedStart, endTime: normalizedEnd, } as ScheduledAppointment; return processed; }); // Compute how many 15-min slots an appointment occupies const getSlotSpan = (apt: ScheduledAppointment): number => { const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5); const endStr = (typeof apt.endTime === "string" ? apt.endTime : formatLocalTime(apt.endTime)).substring(0, 5); const startParts = startStr.split(":"); const endParts = endStr.split(":"); const startH = parseInt(startParts[0] ?? "0", 10); const startM = parseInt(startParts[1] ?? "0", 10); const endH = parseInt(endParts[0] ?? "0", 10); const endM = parseInt(endParts[1] ?? "0", 10); const diff = (endH * 60 + endM) - (startH * 60 + startM); return Math.max(1, Math.round(diff / 15)); }; // Compute display span โ€” same as getSlotSpan but truncated if a later appointment in the // same staff column starts within this appointment's time range (overlap case). const getDisplaySpan = (apt: ScheduledAppointment): number => { const fullSpan = getSlotSpan(apt); const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5); const [startH, startM] = startStr.split(":").map(Number); const startMinutes = (startH ?? 0) * 60 + (startM ?? 0); const nextOverlap = (processedAppointments ?? []) .filter((other) => { if (other.id === apt.id || other.staffId !== apt.staffId) return false; const otherStart = (typeof other.startTime === "string" ? other.startTime : formatLocalTime(other.startTime)).substring(0, 5); const [oh, om] = otherStart.split(":").map(Number); const otherMin = (oh ?? 0) * 60 + (om ?? 0); return otherMin > startMinutes && otherMin < startMinutes + fullSpan * 15; }) .sort((a, b) => { const aStart = (typeof a.startTime === "string" ? a.startTime : formatLocalTime(a.startTime)).substring(0, 5); const bStart = (typeof b.startTime === "string" ? b.startTime : formatLocalTime(b.startTime)).substring(0, 5); return aStart.localeCompare(bStart); })[0]; if (!nextOverlap) return fullSpan; const nextStart = (typeof nextOverlap.startTime === "string" ? nextOverlap.startTime : formatLocalTime(nextOverlap.startTime)).substring(0, 5); const [nh, nm] = nextStart.split(":").map(Number); const nextMin = (nh ?? 0) * 60 + (nm ?? 0); return Math.max(1, Math.round((nextMin - startMinutes) / 15)); }; // Slots that are "continued" rows of a multi-slot appointment (should not render a td) const coveredSlots = new Set(); (processedAppointments ?? []).forEach((apt) => { const span = getDisplaySpan(apt); if (span <= 1) return; const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5); const [startH, startM] = startStr.split(":").map(Number); for (let i = 1; i < span; i++) { const totalMin = (startH ?? 0) * 60 + (startM ?? 0) + i * 15; const h = Math.floor(totalMin / 60).toString().padStart(2, "0"); const m = (totalMin % 60).toString().padStart(2, "0"); coveredSlots.add(`${h}:${m}-${apt.staffId}`); } }); // Check if appointment exists at a specific time slot and staff const getAppointmentAtSlot = (timeSlot: TimeSlot, staffId: number) => { if (!processedAppointments || processedAppointments.length === 0) return undefined; // In appointments for a given time slot, we'll just display the first one // In a real application, you might want to show multiple or stack them const appointmentsAtSlot = processedAppointments.filter((apt) => { const dbTime = typeof apt.startTime === "string" ? apt.startTime.substring(0, 5) : ""; const timeMatches = dbTime === timeSlot.time; const staffMatches = apt.staffId === staffId; return timeMatches && staffMatches; }); return appointmentsAtSlot.length > 0 ? appointmentsAtSlot[0] : undefined; }; const isLoading = isLoadingAppointments || createAppointmentMutation.isPending || updateAppointmentMutation.isPending || deleteAppointmentMutation.isPending; // Define drag item types const ItemTypes = { APPOINTMENT: "appointment", }; // Handle creating a new appointment at a specific time slot and for a specific staff member const handleCreateAppointmentAtSlot = ( timeSlot: TimeSlot, staffId: number, patientId?: number ) => { const startHour = parseInt(timeSlot.time.split(":")[0] as string); const startMinute = parseInt(timeSlot.time.split(":")[1] as string); const startDate = parseLocalDate(selectedDate); startDate.setHours(startHour, startMinute, 0); const endDate = addMinutes(startDate, 30); const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`; const staff = staffMembers.find((s) => Number(s.id) === Number(staffId)); setNewApptPrefill({ staffId: Number(staffId), date: formatLocalDate(selectedDate), startTime: timeSlot.time, endTime, type: staff?.role === "doctor" ? "checkup" : "cleaning", patientId, }); setEditingAppointment(undefined); setIsAddModalOpen(true); }; // Handle moving an appointment to a new time slot and staff const handleMoveAppointment = ( appointmentId: number, newTimeSlot: TimeSlot, newStaffId: number ) => { const appointment = appointments.find((a) => a.id === appointmentId); if (!appointment) return; // Preserve existing duration when moving const apt = appointment as any; const oldStart = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5); const oldEnd = (typeof apt.endTime === "string" ? apt.endTime : formatLocalTime(apt.endTime)).substring(0, 5); const [osH, osM] = oldStart.split(":").map(Number); const [oeH, oeM] = oldEnd.split(":").map(Number); const durationMinutes = ((oeH ?? 0) * 60 + (oeM ?? 0)) - ((osH ?? 0) * 60 + (osM ?? 0)); const effectiveDuration = durationMinutes > 0 ? durationMinutes : 30; const startHour = parseInt(newTimeSlot.time.split(":")[0] as string); const startMinute = parseInt(newTimeSlot.time.split(":")[1] as string); const startDate = parseLocalDate(selectedDate); startDate.setHours(startHour, startMinute, 0); const endDate = addMinutes(startDate, effectiveDuration); const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`; // Find staff member const staff = staffMembers.find((s) => Number(s.id) === newStaffId); const updatedAppointment: UpdateAppointment = { patientId: apt.patientId, userId: apt.userId, staffId: newStaffId, title: apt.title, date: apt.date, type: apt.type, status: apt.status ?? undefined, startTime: newTimeSlot.time, endTime: endTime, notes: apt.notes ?? undefined, }; // Call update mutation updateAppointmentMutation.mutate({ id: appointmentId, appointment: updatedAppointment, }); }; // Open chat window for the patient linked to this appointment const handleChat = (appointmentId: number) => { const apt = appointments.find((a) => a.id === appointmentId); if (!apt) return; const patient = patientsFromDay.find((p) => p.id === (apt as any).patientId); if (!patient) return; const processed = processedAppointments.find((a) => a.id === appointmentId); setChatPatient(patient as Patient); if (processed) { setChatAppointmentInfo({ date: typeof processed.date === "string" ? processed.date : formatLocalDate(processed.date as Date), startTime: typeof processed.startTime === "string" ? processed.startTime.substring(0, 5) : "", }); } else { setChatAppointmentInfo(undefined); } }; // Function to display context menu const handleContextMenu = (e: React.MouseEvent, appointmentId: number) => { // Prevent the default browser context menu e.preventDefault(); // Show our custom context menu with appointment ID as data show({ event: e, props: { appointmentId, }, }); }; // Create a draggable appointment component function DraggableAppointment({ appointment, staff, }: { appointment: ScheduledAppointment; staff: Staff; }) { const [{ isDragging }, drag] = useDrag(() => ({ type: ItemTypes.APPOINTMENT, item: { id: appointment.id }, collect: (monitor) => ({ isDragging: !!monitor.isDragging(), }), })); const resizingRef = useRef(false); const startYRef = useRef(0); const originalSpanRef = useRef(1); const [resizeSpan, setResizeSpan] = useState(null); const currentSpan = getDisplaySpan(appointment); const SLOT_HEIGHT = 57; // h-14 = 56px + 1px border const handleResizeStart = useCallback((e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); resizingRef.current = true; startYRef.current = e.clientY; originalSpanRef.current = currentSpan; const onMouseMove = (ev: MouseEvent) => { if (!resizingRef.current) return; const deltaY = ev.clientY - startYRef.current; const slotDelta = Math.round(deltaY / SLOT_HEIGHT); const newSpan = Math.max(1, originalSpanRef.current + slotDelta); setResizeSpan(newSpan); }; const onMouseUp = (ev: MouseEvent) => { if (!resizingRef.current) return; resizingRef.current = false; document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; const deltaY = ev.clientY - startYRef.current; const slotDelta = Math.round(deltaY / SLOT_HEIGHT); const newSpan = Math.max(1, originalSpanRef.current + slotDelta); setResizeSpan(null); if (newSpan !== originalSpanRef.current && appointment.id) { const startStr = (typeof appointment.startTime === "string" ? appointment.startTime : formatLocalTime(appointment.startTime)).substring(0, 5); const [sH, sM] = startStr.split(":").map(Number); const newEndMinutes = ((sH ?? 0) * 60 + (sM ?? 0)) + newSpan * 15; const endH = Math.floor(newEndMinutes / 60).toString().padStart(2, "0"); const endM = (newEndMinutes % 60).toString().padStart(2, "0"); const newEndTime = `${endH}:${endM}`; const fullApt = appointments.find((a) => a.id === appointment.id) as any; if (fullApt) { updateAppointmentMutation.mutate({ id: appointment.id, appointment: { patientId: fullApt.patientId, userId: fullApt.userId, staffId: fullApt.staffId ?? appointment.staffId, title: fullApt.title, date: fullApt.date, type: fullApt.type, status: fullApt.status ?? undefined, startTime: startStr, endTime: newEndTime, notes: fullApt.notes ?? undefined, }, }); } } }; document.body.style.cursor = "ns-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }, [appointment, currentSpan]); const displayHeight = resizeSpan !== null ? `${resizeSpan * SLOT_HEIGHT - 2}px` : undefined; return (
} className={`${appointmentCardColor(appointment)} border shadow-md rounded p-1 text-xs overflow-visible cursor-move relative ${ isDragging ? "opacity-50" : "opacity-100" }`} style={{ fontWeight: 500, height: displayHeight ?? "100%", ...(resizeSpan !== null ? { zIndex: 20 } : {}), }} onClick={(e) => { if (!isDragging) { const fullAppointment = appointments.find( (a) => a.id === appointment.id ); if (fullAppointment) { e.stopPropagation(); handleEditAppointment(fullAppointment); } } }} onContextMenu={(e) => handleContextMenu(e, appointment.id ?? 0)} >
{appointment.patientName} {appointment.movedByAi && ( AI )}
{getAppointmentTypeLabel(appointment.type)}
{appointment.procedureCodes && appointment.procedureCodes.length > 0 && (
{appointment.procedureCodes.join(", ")}
)} {appointment.notes && (
{appointment.notes}
)} {/* Resize handle at the bottom */}
e.stopPropagation()} >
); } // Create a drop target for appointments function DroppableTimeSlot({ timeSlot, staffId, staffIndex, appointment, staff, rowSpan = 1, }: { timeSlot: TimeSlot; staffId: number; staffIndex: number; appointment: ScheduledAppointment | undefined; staff: Staff; rowSpan?: number; }) { const blocked = !isWithinOfficeHours(timeSlot.time, staffIndex); const [{ isOver, canDrop }, drop] = useDrop(() => ({ accept: ItemTypes.APPOINTMENT, drop: (item: { id: number }) => { if (blocked) { // Store plain data โ€” never store callbacks in state across re-renders setPendingOverride({ type: "move", appointmentId: item.id, timeSlot, staffId }); } else { handleMoveAppointment(item.id, timeSlot, staffId); } }, canDrop: () => !appointment, collect: (monitor) => ({ isOver: !!monitor.isOver(), canDrop: !!monitor.canDrop(), }), })); const handleClickEmpty = () => { if (blocked) { setPendingOverride({ type: "create", timeSlot, staffId }); } else { handleCreateAppointmentAtSlot(timeSlot, staffId); } }; return ( } key={`${timeSlot.time}-${staffId}`} className={`px-1 py-1 border relative h-14 ${ isOver && canDrop ? "bg-green-100" : blocked ? "bg-gray-100" : "" }`} rowSpan={rowSpan > 1 ? rowSpan : undefined} title={blocked ? "Outside office hours โ€” click to override" : undefined} > {appointment ? ( ) : ( )} ); } // ------------------- // appointment page โ€” update these handlers const handleCheckEligibility = (appointmentId: number) => { const apt = appointments.find((a) => a.id === appointmentId); const patient = apt ? patientsFromDay.find((p) => p.id === apt.patientId) : null; const insuranceProvider = (patient as any)?.insuranceProvider as string | null ?? null; const p = insuranceProvider?.toLowerCase() ?? ""; const isMassHealth = p.includes("masshealth"); let autoCheck = "other-providers"; if (isMassHealth) { const dob = (patient as any)?.dateOfBirth; let age: number | null = null; if (dob) { const dobDate = typeof dob === "string" ? new Date(dob) : dob as Date; const today = new Date(); age = today.getFullYear() - dobDate.getFullYear(); const m = today.getMonth() - dobDate.getMonth(); if (m < 0 || (m === 0 && today.getDate() < dobDate.getDate())) age--; } autoCheck = age !== null && age >= 21 ? "mh-history" : "cmsp"; } else if (p.includes("delta dental ma")) { autoCheck = "ddma"; } else if (p.includes("delta dental ins")) { autoCheck = "delta-ins"; } else if (p.includes("united healthcare sco") || p.includes("united sco")) { autoCheck = "united-sco"; } else if (p.includes("tufts") || p.includes("dentaquest")) { autoCheck = "tufts-sco"; } else if (p.includes("commonwealth care alliance") || p.includes("cca")) { autoCheck = "cca"; } if (autoCheck === "other-providers") { setLocation(`/insurance-status?appointmentId=${appointmentId}&scrollTo=other-providers`); } else { setLocation(`/insurance-status?appointmentId=${appointmentId}&autoCheck=${autoCheck}`); } }; const handleClaimsPreAuth = (appointmentId: number) => { setLocation(`/claims?appointmentId=${appointmentId}`); }; const handleSelectProcedures = (appointmentId: number) => { const appt = appointments.find((a) => a.id === appointmentId); if (!appt) return; setSelectProceduresAppointmentId(appointmentId); setSelectProceduresPatientId(appt.patientId); setIsSelectProceduresOpen(true); }; const handlePayments = (appointmentId: number) => { setLocation(`/payments?appointmentId=${appointmentId}`); }; const handleChartPlan = (appointmentId: number) => { console.log( `Viewing chart/treatment plan for appointment: ${appointmentId}` ); }; const handleClinicNotes = (appointmentId: number) => { console.log(`Opening clinic notes for appointment: ${appointmentId}`); }; const handleManualConfirm = async (appointmentId: number) => { try { await apiRequest("PATCH", `/api/appointments/${appointmentId}/confirm`); queryClient.invalidateQueries({ queryKey: qkAppointmentsDay(formattedSelectedDate) }); toast({ title: "Confirmed", description: "AI-moved label removed. Appointment manually confirmed." }); } catch (err: any) { toast({ title: "Error", description: err?.message ?? "Failed to confirm appointment.", variant: "destructive" }); } }; const handleCheckAllEligibilities = async () => { if (!user) { toast({ title: "Unauthorized", description: "Please login to perform this action.", variant: "destructive", }); return; } const dateParam = formattedSelectedDate; // existing variable in your component const staffIdsParam = `&staffIds=${Array.from(selectedStaffColumns).join(",")}`; // Start: set redux task status (visible globally) dispatch( setTaskStatus({ key: "eligibilityBatchCheck", status: "pending", message: `Checking eligibility for appointments on ${dateParam}...`, }) ); setIsCheckingAllElig(true); setProcessedAppointmentIds({}); try { const res = await apiRequest( "POST", `/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}${staffIdsParam}`, {} ); // read body for all cases so we can show per-appointment info let body: any; try { body = await res.json(); } catch (e) { body = null; } if (!res.ok) { const errMsg = body?.error ?? `Server error ${res.status}`; // global error dispatch( setTaskStatus({ key: "eligibilityBatchCheck", status: "error", message: `Batch eligibility failed: ${errMsg}`, }) ); toast({ title: "Batch check failed", description: errMsg, variant: "destructive", }); return; } const results: any[] = Array.isArray(body?.results) ? body.results : []; // Map appointmentId -> appointment so we can show human friendly toasts const appointmentMap = new Map(); for (const a of appointments) { if (a && typeof a.id === "number") appointmentMap.set(a.id as number, a); } // Counters for summary let successCount = 0; let skippedCount = 0; let warningCount = 0; // Show toast for each skipped appointment (error) and for warnings. for (const r of results) { const aptId = Number(r.appointmentId); const apt = appointmentMap.get(aptId); const patientName = apt ? patientsFromDay.find((p) => p.id === apt.patientId) ? `${patientsFromDay.find((p) => p.id === apt.patientId)!.firstName ?? ""} ${patientsFromDay.find((p) => p.id === apt.patientId)!.lastName ?? ""}`.trim() : `patient#${apt.patientId ?? "?"}` : `appointment#${aptId}`; const aptTime = apt ? `${apt.date ?? ""} ${apt.startTime ?? ""}` : ""; if (r.error) { skippedCount++; toast({ title: `Skipped: ${patientName}`, description: `${aptTime} โ€” ${r.error}`, variant: "destructive", }); console.warn("[batch skipped]", aptId, r.error); } else if (r.warning) { warningCount++; toast({ title: `Warning: ${patientName}`, description: `${aptTime} โ€” ${r.warning}`, variant: "destructive", }); console.info("[batch warning]", aptId, r.warning); } else if (r.processed) { successCount++; // optional: show small non-intrusive toast or nothing for each success. // comment-in to notify successes (may create many toasts): // toast({ title: `Processed: ${patientName}`, description: `${aptTime}`, variant: "default" }); } else { // fallback: treat as skipped skippedCount++; toast({ title: `Skipped: ${patientName}`, description: `${aptTime} โ€” Unknown reason`, variant: "destructive", }); } } // Invalidate queries so UI repaints with updated patient statuses queryClient.invalidateQueries({ queryKey: qkAppointmentsDay(formattedSelectedDate), }); // global success status (summary) dispatch( setTaskStatus({ key: "eligibilityBatchCheck", status: skippedCount > 0 ? "error" : "success", message: `Batch processed ${results.length} appointments โ€” success: ${successCount}, warnings: ${warningCount}, skipped: ${skippedCount}.`, }) ); // also show final toast summary toast({ title: "Batch complete", description: `Processed ${results.length} appointments โ€” success: ${successCount}, warnings: ${warningCount}, skipped: ${skippedCount}.`, variant: skippedCount > 0 ? "destructive" : "default", }); } catch (err: any) { console.error("[check-all-eligibilities] error", err); dispatch( setTaskStatus({ key: "eligibilityBatchCheck", status: "error", message: `Batch eligibility error: ${err?.message ?? String(err)}`, }) ); toast({ title: "Batch check failed", description: err?.message ?? String(err), variant: "destructive", }); } finally { setIsCheckingAllElig(false); // intentionally do not clear task status here so banner persists until user dismisses it } }; const handleClaimForColumn = async () => { if (!user || selectedClaimColumns.size === 0) return; const staffIdsParam = Array.from(selectedClaimColumns).join(","); dispatch( setTaskStatus({ key: "claimBatchCheck", status: "pending", message: `Submitting claims for selected columns on ${formattedSelectedDate}...`, }) ); setIsClaimingColumn(true); try { const res = await apiRequest( "POST", `/api/claims/batch-column?date=${formattedSelectedDate}&staffIds=${staffIdsParam}`, {} ); let body: any; try { body = await res.json(); } catch { body = null; } if (!res.ok) { const errMsg = body?.error ?? `Server error ${res.status}`; dispatch(setTaskStatus({ key: "claimBatchCheck", status: "error", message: `Batch claim failed: ${errMsg}` })); toast({ title: "Batch claim failed", description: errMsg, variant: "destructive" }); return; } const results: any[] = Array.isArray(body?.results) ? body.results : []; const appointmentMap = new Map(); for (const a of appointments) { if (a && typeof a.id === "number") appointmentMap.set(a.id, a); } let queued = 0, skippedNoProcedures = 0, skippedAlreadyClaimed = 0, errCount = 0; for (const r of results) { const aptId = Number(r.appointmentId); const apt = appointmentMap.get(aptId); const patientName = apt ? patientsFromDay.find((p) => p.id === apt.patientId) ? `${patientsFromDay.find((p) => p.id === apt.patientId)!.firstName ?? ""} ${patientsFromDay.find((p) => p.id === apt.patientId)!.lastName ?? ""}`.trim() : `patient#${apt.patientId}` : `appointment#${aptId}`; if (r.skipped && r.error === "Already claimed") { skippedAlreadyClaimed++; } else if (r.skipped) { skippedNoProcedures++; } else if (r.error) { errCount++; toast({ title: `Skipped: ${patientName}`, description: r.error, variant: "destructive" }); } else if (r.processed) { queued++; } } queryClient.invalidateQueries({ queryKey: qkAppointmentsDay(formattedSelectedDate) }); dispatch( setTaskStatus({ key: "claimBatchCheck", status: errCount > 0 ? "error" : "success", message: `Claims queued: ${queued}, already claimed: ${skippedAlreadyClaimed}, no procedures: ${skippedNoProcedures}, errors: ${errCount}.`, }) ); toast({ title: "Claim batch queued", description: `Queued: ${queued}, already claimed: ${skippedAlreadyClaimed}, no procedures: ${skippedNoProcedures}, errors: ${errCount}.`, variant: errCount > 0 ? "destructive" : "default", }); } catch (err: any) { dispatch(setTaskStatus({ key: "claimBatchCheck", status: "error", message: `Batch claim error: ${err?.message ?? String(err)}` })); toast({ title: "Batch claim failed", description: err?.message ?? String(err), variant: "destructive" }); } finally { setIsClaimingColumn(false); } }; const handleSendRemindersForColumn = async () => { if (!user || selectedReminderColumns.size === 0) return; setIsSendingReminders(true); try { const res = await apiRequest("POST", "/api/twilio/send-reminders-batch", { date: formattedSelectedDate, staffIds: Array.from(selectedReminderColumns), aiFollowUp: reminderAiFollowUp, }); const { sent, skipped } = await res.json(); toast({ title: "Text Reminders Sent", description: `Sent ${sent} reminder${sent !== 1 ? "s" : ""}${skipped > 0 ? `, skipped ${skipped} (no phone)` : ""}.`, }); } catch (err: any) { toast({ title: "Failed to Send Reminders", description: err?.message ?? String(err), variant: "destructive" }); } finally { setIsSendingReminders(false); } }; const handleSendRescheduleForColumn = async () => { if (!user || selectedRescheduleColumns.size === 0) return; setIsSendingReschedule(true); try { const res = await apiRequest("POST", "/api/twilio/send-reschedule-batch", { date: formattedSelectedDate, staffIds: Array.from(selectedRescheduleColumns), aiFollowUp: rescheduleAiFollowUp, }); const { sent, skipped } = await res.json(); toast({ title: "Reschedule Messages Sent", description: `Sent ${sent} message${sent !== 1 ? "s" : ""}${skipped > 0 ? `, skipped ${skipped} (no phone)` : ""}.`, }); } catch (err: any) { toast({ title: "Failed to Send Reschedule Messages", description: err?.message ?? String(err), variant: "destructive" }); } finally { setIsSendingReschedule(false); } }; const handleDownloadClaimPdfs = async () => { if (!user || selectedDownloadPdfColumns.size === 0) return; const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(","); setIsDownloadingClaimPdfs(true); try { const res = await apiRequest( "GET", `/api/claims/batch-pdf?date=${formattedSelectedDate}&staffIds=${staffIdsParam}` ); if (!res.ok) { let errMsg = `Server error ${res.status}`; try { const body = await res.json(); errMsg = body?.error ?? errMsg; } catch { /* ignore */ } toast({ title: "Download failed", description: errMsg, variant: "destructive" }); return; } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `claims_${formattedSelectedDate}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast({ title: "Download started", description: `Claim PDFs for ${formattedSelectedDate} saved to your Downloads folder.` }); } catch (err: any) { toast({ title: "Download failed", description: err?.message ?? String(err), variant: "destructive" }); } finally { setIsDownloadingClaimPdfs(false); } }; // โ”€โ”€ 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} today`, 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 if (data.action === "need_cdt_clarification" && data.actionData) { const phrases: string[] = data.actionData.unknownPhrases ?? []; const inputs: Record = {}; for (const p of phrases) inputs[p] = ""; setAiClaimCdtClarification({ unknownPhrases: phrases, codeInputs: inputs, originalMessage: `claim ${apt.notes} for ${apt.patientName}`, aptDate, appointmentId: Number(apt.id), patientName: apt.patientName, notes: apt.notes ?? "", matchedSoFar: data.actionData.matchedSoFar ?? [], }); } 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; // Include current patient in queue; claims-page advances it only on successful submit. const fromCurrent = aiClaimQueue.slice(aiClaimCurrentIndex); if (fromCurrent.length > 0) { sessionStorage.setItem("ai_claim_queue", JSON.stringify({ appointments: fromCurrent, 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 () => { setAiClaimCdtClarification(null); const nextIndex = aiClaimCurrentIndex + 1; setAiClaimCurrentIndex(nextIndex); await processAiClaimAtIndex(aiClaimQueue, nextIndex); }; const handleAiClaimCdtSubmit = async () => { if (!aiClaimCdtClarification) return; const { codeInputs, originalMessage, aptDate, appointmentId, patientName, notes, matchedSoFar } = aiClaimCdtClarification; setAiClaimCdtClarification(null); setIsAiClaimProcessing(true); setAiClaimCurrentData(null); try { for (const [phrase, code] of Object.entries(codeInputs)) { await apiRequest("POST", "/api/ai/cdt-aliases/add", { phrase, cdtCode: code.trim() }); } const res = await apiRequest("POST", "/api/ai/internal-chat", { message: originalMessage, 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, patientName, notes, reply: data.reply ?? "", }); } else if (data.action === "need_cdt_clarification" && data.actionData) { // Still has unknowns โ€” loop again const phrases: string[] = data.actionData.unknownPhrases ?? []; const inputs: Record = {}; for (const p of phrases) inputs[p] = ""; setAiClaimCdtClarification({ unknownPhrases: phrases, codeInputs: inputs, originalMessage, aptDate, appointmentId, patientName, notes, matchedSoFar: data.actionData.matchedSoFar ?? matchedSoFar, }); } else { setAiClaimCurrentData({ matchedCodes: matchedSoFar.map((c) => ({ ...c, toothNumber: undefined, toothSurface: undefined })), siteKey: "", serviceDate: aptDate, appointmentId, patientName, notes, reply: data.reply ?? "Could not fully interpret notes.", }); } } catch { setAiClaimCurrentData({ matchedCodes: matchedSoFar.map((c) => ({ ...c, toothNumber: undefined, toothSurface: undefined })), siteKey: "", serviceDate: aptDate, appointmentId, patientName, notes, reply: "Error retrying after alias save.", }); } finally { setIsAiClaimProcessing(false); } }; return (
dispatch(clearTaskStatus("eligibilityBatchCheck"))} /> dispatch(clearTaskStatus("claimBatchCheck"))} />

Appointment Schedule

View and manage the dental practice schedule

{/* Check Eligibility for Column section */}
{staffMembers.map((staff, index) => ( ))}
{/* Claim for Column section */}
{staffMembers.map((staff, index) => ( ))}
{/* Claim for Column with AI section */}
{staffMembers.map((staff, index) => ( ))}
{autoClaimEnabled && ( )}
{/* Text Reminder for Column section */}
{staffMembers.map((staff, index) => ( ))}
AI follow up
{/* Reschedule for Column section */}
{staffMembers.map((staff, index) => ( ))}
AI follow up
{/* Download Claim PDF for Column section */}
{staffMembers.map((staff, index) => ( ))}
{/* Context Menu */} { const fullAppointment = appointments.find( (a) => a.id === props.appointmentId ); if (fullAppointment) { handleEditAppointment(fullAppointment); } }} > Edit Appointment handleDeleteAppointment(props.appointmentId) } > Delete Appointment {/* Select Procedures */} handleSelectProcedures(props.appointmentId)} > Select Procedures {/* Check Eligibility */} handleCheckEligibility(props.appointmentId)} > Check Eligibility {/* Claims / PreAuth */} handleClaimsPreAuth(props.appointmentId)} > Claims/PreAuth {/* Payments */} handlePayments(props.appointmentId)}> Payments {/* Chart / Treatment Plan */} handleChartPlan(props.appointmentId)}> Chart / Treatment Plan {/* Clinic Notes */} handleClinicNotes(props.appointmentId)}> Clinic Notes {/* Chat */} handleChat(props.appointmentId)}> Chat {/* Manually Confirmed โ€” only relevant for AI-moved appointments */} handleManualConfirm(props.appointmentId)}> Manually Confirmed {/* Main Content */}
{/* Schedule Grid */}
{/* << prev week */} {/* < prev day */} {/* today circle */} {/* > next day */} {/* >> next week */}
{/* Top button with popover calendar */}
{ if (date) setSelectedDate(date); }} onClose={() => setCalendarOpen(false)} />
{/* Office Hours Summary */} {(() => { const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const; const dayName = dayNames[selectedDate.getDay()]!; const fmt = (t: string) => { const [hh, mm] = t.split(":").map(Number); const period = (hh ?? 0) >= 12 ? "PM" : "AM"; const h12 = (hh ?? 0) > 12 ? (hh ?? 0) - 12 : (hh ?? 0) === 0 ? 12 : (hh ?? 0); return `${h12}:${String(mm ?? 0).padStart(2,"0")} ${period}`; }; const doctorHours = officeHours?.doctors?.[dayName]; const hygHours = officeHours?.hygienists?.[dayName]; const isOverride = officeHours?.overrideDates?.includes(selectedDate.toLocaleDateString("en-CA")); return (
Office Hours
{!officeHours ? ( Not configured โ€” ) : isOverride ? ( Override active โ€” all slots open today ) : ( <> Doctors (Aโ€“C):{" "} {doctorHours?.enabled ? `${fmt(doctorHours.amStart)}โ€“${fmt(doctorHours.amEnd)}, ${fmt(doctorHours.pmStart)}โ€“${fmt(doctorHours.pmEnd)}` : Closed} Hygienists (Dโ€“F):{" "} {hygHours?.enabled ? `${fmt(hygHours.amStart)}โ€“${fmt(hygHours.amEnd)}, ${fmt(hygHours.pmStart)}โ€“${fmt(hygHours.pmEnd)}` : Closed} )}
); })()} {/* Schedule Grid with Drag and Drop */}
{staffMembers.map((staff, index) => ( ))} {timeSlots.map((timeSlot) => ( {staffMembers.map((staff, staffIndex) => { if (coveredSlots.has(`${timeSlot.time}-${staff.id}`)) return null; const apt = getAppointmentAtSlot(timeSlot, Number(staff.id)); const span = apt ? getDisplaySpan(apt) : 1; return ( ); })} ))}
Time {editingLabelStaffId === String(staff.id) ? ( handleLabelSave(String(staff.id), e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleLabelSave(String(staff.id), e.currentTarget.value); if (e.key === "Escape") setEditingLabelStaffId(null); }} autoFocus /> ) : (
setEditingLabelStaffId(String(staff.id))} > {columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
)}
{staff.name}
{staff.role}
{timeSlot.displayTime}
{/* Add/Edit Appointment Modal */} { setIsAddModalOpen(open); if (!open) setNewApptPrefill(null); }} onSubmit={handleAppointmentSubmit} isLoading={ createAppointmentMutation.isPending || updateAppointmentMutation.isPending } appointment={editingAppointment} prefillData={editingAppointment ? null : newApptPrefill} onDelete={handleDeleteAppointment} /> setConfirmDeleteState({ open: false })} entityName={String(confirmDeleteState.appointmentId)} /> {/* Outside-office-hours override dialog */} {pendingOverride && (

Outside Office Hours

This time slot is outside the configured office hours. Do you want to schedule here anyway?

)} {/* Chat popup */} {chatPatient && (
{ setChatPatient(null); setChatAppointmentInfo(undefined); }} />
)} {/* Select Procedures modal โ€” stays on appointments page */} {isSelectProceduresOpen && selectProceduresPatientId !== null && ( { setIsSelectProceduresOpen(false); setSelectProceduresPatientId(null); setSelectProceduresAppointmentId(null); }} onSubmit={async () => { throw new Error("not used in proceduresOnly mode"); }} onHandleAppointmentSubmit={async () => 0} onHandleUpdatePatient={(_patient: UpdatePatient & { id: number }) => {}} onHandleForMHSeleniumClaim={() => {}} onHandleForMHSeleniumClaimPreAuth={() => {}} onHandleForCCASeleniumClaim={() => {}} onHandleForCCASeleniumPreAuth={() => {}} onHandleForUnitedDHSeleniumPreAuth={() => {}} onHandleForDDMASeleniumClaim={() => {}} onHandleForUnitedDHSeleniumClaim={() => {}} onHandleForTuftsSCOSeleniumPreAuth={() => {}} onHandleForTuftsSCOSeleniumClaim={() => {}} /> )} {/* AI Claim Queue Modal */} {aiClaimModalOpen && (

AI Claim Queue

{aiClaimCurrentIndex + 1} / {aiClaimQueue.length}
{isAiClaimProcessing ? (
Interpreting notes with AI...
) : aiClaimCdtClarification ? (

{aiClaimCdtClarification.patientName}

Notes: {aiClaimCdtClarification.notes}

Unknown term{aiClaimCdtClarification.unknownPhrases.length > 1 ? "s" : ""} โ€” enter CDT code{aiClaimCdtClarification.unknownPhrases.length > 1 ? "s" : ""}:

{aiClaimCdtClarification.matchedSoFar.length > 0 && (

Already matched

{aiClaimCdtClarification.matchedSoFar.map((c) => (

{c.code} โ€” {c.description}

))}
)}
{aiClaimCdtClarification.unknownPhrases.map((phrase) => (
"{phrase}" โ†’ setAiClaimCdtClarification((prev) => prev ? { ...prev, codeInputs: { ...prev.codeInputs, [phrase]: e.target.value.toUpperCase() } } : prev ) } className="flex-1 rounded border border-amber-300 bg-white px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-amber-400" />
))}
) : aiClaimCurrentData ? (

{aiClaimCurrentData.patientName}

{aiClaimCurrentData.notes && (

Notes: {aiClaimCurrentData.notes}

)}

{aiClaimCurrentData.reply}

{aiClaimCurrentData.matchedCodes.length > 0 ? (
{aiClaimCurrentData.matchedCodes.map((c) => (

{c.code} โ€” {c.description} {c.toothNumber && (#{c.toothNumber})}

))}
) : (

No procedures could be matched from notes. Skip this appointment or cancel.

)}
{aiClaimCurrentData.matchedCodes.length > 0 && ( )}
) : null}
)}
); }