Files
DentalManagementMH06/apps/Frontend/src/pages/appointments-page.tsx
Gitead cb49298b66 fix: teach AI to recognize recement and fix missing service date in column claim
Add "recement" example to LLM classifier so it spreads across multiple
teeth (recement #5, #3 → two D2920 entries). Add "today" to the AI
column claim message so the LLM sets appointmentDate instead of asking
"Which service date?"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 00:02:26 -04:00

2562 lines
99 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Date>(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<number, boolean>
>({});
const [selectedStaffColumns, setSelectedStaffColumns] = useState<Set<number>>(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<Set<number>>(new Set());
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
const [selectedClaimAiColumns, setSelectedClaimAiColumns] = useState<Set<number>>(new Set());
const [isClaimingAiColumn, setIsClaimingAiColumn] = useState(false);
const [autoClaimEnabled, setAutoClaimEnabled] = useState(false);
const [autoClaimTime, setAutoClaimTime] = useState("19:00");
const autoClaimFiredHourRef = useRef<number | null>(null);
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 [aiClaimCdtClarification, setAiClaimCdtClarification] = useState<{
unknownPhrases: string[];
codeInputs: Record<string, string>;
originalMessage: string;
aptDate: string;
appointmentId: number;
patientName: string;
notes: string;
matchedSoFar: Array<{ code: string; description: string }>;
} | null>(null);
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true);
const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState<Set<number>>(new Set());
const [isSendingReschedule, setIsSendingReschedule] = useState(false);
const [rescheduleAiFollowUp, setRescheduleAiFollowUp] = useState(true);
const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState<Set<number>>(new Set());
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [editingLabelStaffId, setEditingLabelStaffId] = useState<string | null>(null);
const [pendingOverride, setPendingOverride] = useState<{
type: "move" | "create";
appointmentId?: number;
timeSlot: TimeSlot;
staffId: number;
} | null>(null);
const [newApptPrefill, setNewApptPrefill] = useState<NewAppointmentPrefill | null>(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<number | null>(null);
const [selectProceduresAppointmentId, setSelectProceduresAppointmentId] = useState<number | null>(null);
// Chat popup state
const [chatPatient, setChatPatient] = useState<Patient | null>(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<OfficeHoursData | null>({
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<Staff[]>({
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<string, string>;
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<string>();
(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<number | null>(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 (
<div
ref={drag as unknown as React.RefObject<HTMLDivElement>}
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)}
>
<PatientStatusBadge
status={resolveAppointmentBadgeStatus(appointment)}
className="pointer-events-auto"
size={30}
/>
<div className="font-bold truncate flex items-center gap-1">
<Move className="h-3 w-3" />
{appointment.patientName}
{appointment.movedByAi && (
<span
className="ml-1 inline-flex items-center gap-0.5 rounded bg-teal-600 px-1 py-0.5 text-[10px] font-semibold text-white leading-none"
title="Rescheduled by AI — pending human confirmation"
>
<Bot className="h-2.5 w-2.5" />
AI
</span>
)}
</div>
<div className="truncate text-[11px]">
{getAppointmentTypeLabel(appointment.type)}
</div>
{appointment.procedureCodes && appointment.procedureCodes.length > 0 && (
<div className="truncate text-[10px] opacity-80">
{appointment.procedureCodes.join(", ")}
</div>
)}
{appointment.notes && (
<div className="truncate text-[10px] opacity-90 font-semibold">
{appointment.notes}
</div>
)}
{/* Resize handle at the bottom */}
<div
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center group hover:bg-black/10 rounded-b"
onMouseDown={handleResizeStart}
onClick={(e) => e.stopPropagation()}
>
<div className="w-8 h-0.5 bg-current opacity-30 group-hover:opacity-70 rounded" />
</div>
</div>
);
}
// 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 (
<td
ref={drop as unknown as React.RefObject<HTMLTableCellElement>}
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 ? (
<DraggableAppointment appointment={appointment} staff={staff} />
) : (
<button
className={`w-full h-full rounded flex items-center justify-center ${
isOver && canDrop
? "bg-green-100"
: blocked
? "text-gray-300 hover:bg-gray-200"
: "text-gray-400 hover:bg-gray-100"
}`}
onClick={handleClickEmpty}
>
<Plus className="h-4 w-4" />
</button>
)}
</td>
);
}
// -------------------
// 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<number, Appointment>();
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<number, Appointment>();
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<string, string> = {};
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<string, string> = {};
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 (
<div>
<SeleniumTaskBanner
status={batchTask.status}
message={batchTask.message}
show={batchTask.show}
onClear={() => dispatch(clearTaskStatus("eligibilityBatchCheck"))}
/>
<SeleniumTaskBanner
status={claimBatchTask.status}
message={claimBatchTask.message}
show={claimBatchTask.show}
onClear={() => dispatch(clearTaskStatus("claimBatchCheck"))}
/>
<div className="container mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Appointment Schedule
</h1>
<p className="text-muted-foreground">
View and manage the dental practice schedule
</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<Button
onClick={() => {
setEditingAppointment(undefined);
setIsAddModalOpen(true);
}}
disabled={isLoading}
>
<Plus className="h-4 w-4" />
New Appointment
</Button>
{/* Check Eligibility for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleCheckAllEligibilities()}
disabled={isLoading || isCheckingAllElig || selectedStaffColumns.size === 0}
size="sm"
>
{isCheckingAllElig ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
Checking...
</>
) : (
<>
<Shield className="h-4 w-4 mr-1" />
Check Eligibility for Column
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedStaffColumns.has(Number(staff.id))}
onChange={() => toggleStaffColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
</div>
{/* Claim for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleClaimForColumn()}
disabled={isLoading || isClaimingColumn || selectedClaimColumns.size === 0}
size="sm"
>
{isClaimingColumn ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
Submitting...
</>
) : (
<>
<FileCheck className="h-4 w-4 mr-1" />
Claim for Column
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedClaimColumns.has(Number(staff.id))}
onChange={() => toggleClaimColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
</div>
{/* Claim for Column with AI section */}
<div className="flex flex-wrap items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleClaimForColumnWithAi()}
disabled={isLoading || isClaimingAiColumn || selectedClaimAiColumns.size === 0}
size="sm"
>
{isClaimingAiColumn ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
Submitting...
</>
) : (
<>
<Bot className="h-4 w-4 mr-1" />
Claim for Column with AI
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedClaimAiColumns.has(Number(staff.id))}
onChange={() => toggleClaimAiColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
<div className="flex items-center gap-2 border-l pl-3 ml-1">
<Switch
id="auto-claim-toggle"
checked={autoClaimEnabled}
onCheckedChange={setAutoClaimEnabled}
/>
<Label htmlFor="auto-claim-toggle" className="text-sm font-medium cursor-pointer whitespace-nowrap">
Auto Claim
</Label>
{autoClaimEnabled && (
<Select value={autoClaimTime} onValueChange={setAutoClaimTime}>
<SelectTrigger className="h-8 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, h) => {
const value = `${String(h).padStart(2, "0")}:00`;
const label = h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`;
return (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
</div>
</div>
{/* Text Reminder for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleSendRemindersForColumn()}
disabled={isLoading || isSendingReminders || selectedReminderColumns.size === 0}
size="sm"
>
{isSendingReminders ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
Sending...
</>
) : (
<>
<MessageSquare className="h-4 w-4 mr-1" />
Text Reminder for Column
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedReminderColumns.has(Number(staff.id))}
onChange={() => toggleReminderColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
<div className="flex items-center gap-1.5 ml-2 pl-2 border-l border-gray-200">
<button
type="button"
role="switch"
aria-checked={reminderAiFollowUp}
onClick={() => setReminderAiFollowUp((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${reminderAiFollowUp ? "bg-teal-600" : "bg-gray-300"}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${reminderAiFollowUp ? "translate-x-4" : "translate-x-0"}`}
/>
</button>
<span className="text-xs text-gray-600 whitespace-nowrap">AI follow up</span>
</div>
</div>
{/* Reschedule for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleSendRescheduleForColumn()}
disabled={isLoading || isSendingReschedule || selectedRescheduleColumns.size === 0}
size="sm"
>
{isSendingReschedule ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
Sending...
</>
) : (
<>
<MessageSquare className="h-4 w-4 mr-1" />
Reschedule for Column
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedRescheduleColumns.has(Number(staff.id))}
onChange={() => toggleRescheduleColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
<div className="flex items-center gap-1.5 ml-2 pl-2 border-l border-gray-200">
<button
type="button"
role="switch"
aria-checked={rescheduleAiFollowUp}
onClick={() => setRescheduleAiFollowUp((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${rescheduleAiFollowUp ? "bg-teal-600" : "bg-gray-300"}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${rescheduleAiFollowUp ? "translate-x-4" : "translate-x-0"}`}
/>
</button>
<span className="text-xs text-gray-600 whitespace-nowrap">AI follow up</span>
</div>
</div>
{/* Download Claim PDF for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
onClick={() => handleDownloadClaimPdfs()}
disabled={isLoading || isDownloadingClaimPdfs || selectedDownloadPdfColumns.size === 0}
size="sm"
>
{isDownloadingClaimPdfs ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
Downloading...
</>
) : (
<>
<Download className="h-4 w-4 mr-1" />
Download Claim PDF for Column
</>
)}
</Button>
{staffMembers.map((staff, index) => (
<label
key={staff.id}
className="flex items-center gap-1 cursor-pointer select-none"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
checked={selectedDownloadPdfColumns.has(Number(staff.id))}
onChange={() => toggleDownloadPdfColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
</div>
</div>
</div>
{/* Context Menu */}
<Menu id={APPOINTMENT_CONTEXT_MENU_ID} animation="fade">
<Item
onClick={({ props }) => {
const fullAppointment = appointments.find(
(a) => a.id === props.appointmentId
);
if (fullAppointment) {
handleEditAppointment(fullAppointment);
}
}}
>
<span className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4" />
Edit Appointment
</span>
</Item>
<Item
onClick={({ props }) =>
handleDeleteAppointment(props.appointmentId)
}
>
<span className="flex items-center gap-2 text-red-600">
<Trash2 className="h-4 w-4" />
Delete Appointment
</span>
</Item>
{/* Select Procedures */}
<Item
onClick={({ props }) => handleSelectProcedures(props.appointmentId)}
>
<span className="flex items-center gap-2 text-purple-600">
<Stethoscope className="h-4 w-4" />
Select Procedures
</span>
</Item>
{/* Check Eligibility */}
<Item
onClick={({ props }) => handleCheckEligibility(props.appointmentId)}
>
<span className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Check Eligibility
</span>
</Item>
{/* Claims / PreAuth */}
<Item
onClick={({ props }) => handleClaimsPreAuth(props.appointmentId)}
>
<span className="flex items-center gap-2">
<FileCheck className="h-4 w-4" />
Claims/PreAuth
</span>
</Item>
{/* Payments */}
<Item onClick={({ props }) => handlePayments(props.appointmentId)}>
<span className="flex items-center gap-2 text-green-600">
<CreditCard className="h-4 w-4" />
Payments
</span>
</Item>
{/* Chart / Treatment Plan */}
<Item onClick={({ props }) => handleChartPlan(props.appointmentId)}>
<span className="flex items-center gap-2">
<ClipboardList className="h-4 w-4" />
Chart / Treatment Plan
</span>
</Item>
{/* Clinic Notes */}
<Item onClick={({ props }) => handleClinicNotes(props.appointmentId)}>
<span className="flex items-center gap-2 text-yellow-600">
<StickyNote className="h-4 w-4" />
Clinic Notes
</span>
</Item>
{/* Chat */}
<Item onClick={({ props }) => handleChat(props.appointmentId)}>
<span className="flex items-center gap-2 text-blue-600">
<MessageSquare className="h-4 w-4" />
Chat
</span>
</Item>
{/* Manually Confirmed — only relevant for AI-moved appointments */}
<Item onClick={({ props }) => handleManualConfirm(props.appointmentId)}>
<span className="flex items-center gap-2 text-teal-700">
<UserCheck className="h-4 w-4" />
Manually Confirmed
</span>
</Item>
</Menu>
{/* Main Content */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Schedule Grid */}
<div className="w-full overflow-x-auto bg-white rounded-md shadow">
<div className="p-4 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
{/* << prev week */}
<Button
variant="outline"
size="icon"
title="Previous week"
onClick={() => setSelectedDate(addWeeks(selectedDate, -1))}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
{/* < prev day */}
<Button
variant="outline"
size="icon"
title="Previous day"
onClick={() => setSelectedDate(addDays(selectedDate, -1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* today circle */}
<Button
variant="outline"
title="Go to today"
onClick={() => setSelectedDate(startOfToday())}
className="w-auto px-3 font-semibold text-sm"
>
{selectedDate.toLocaleDateString('en-US', { weekday: 'long' })}&nbsp;·&nbsp;{`${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}-${selectedDate.getFullYear()}`}
</Button>
{/* > next day */}
<Button
variant="outline"
size="icon"
title="Next day"
onClick={() => setSelectedDate(addDays(selectedDate, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
{/* >> next week */}
<Button
variant="outline"
size="icon"
title="Next week"
onClick={() => setSelectedDate(addWeeks(selectedDate, 1))}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
{/* Top button with popover calendar */}
<div className="flex items-center gap-2">
<Label className="hidden sm:flex">Selected</Label>
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-[160px] justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate
? formatDateToHumanReadable(selectedDate)
: "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto">
<Calendar
mode="single"
selected={selectedDate}
onSelect={(date) => {
if (date) setSelectedDate(date);
}}
onClose={() => setCalendarOpen(false)}
/>
</PopoverContent>
</Popover>
</div>
</div>
</div>
{/* 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 (
<div className="px-4 pb-3 flex items-center gap-4 flex-wrap text-xs text-gray-500 border-b">
<div className="flex items-center gap-1.5 font-medium text-gray-700">
<Clock className="h-3.5 w-3.5 text-teal-600" />
<span>Office Hours</span>
<button onClick={() => setLocation("/settings/officehours")} className="ml-1 text-teal-600 hover:text-teal-700" title="Edit office hours">
<ExternalLink className="h-3 w-3" />
</button>
</div>
{!officeHours ? (
<span className="italic text-gray-400">Not configured <button onClick={() => setLocation("/settings/officehours")} className="text-teal-600 underline">set up office hours</button></span>
) : isOverride ? (
<span className="text-teal-600 font-medium">Override active all slots open today</span>
) : (
<>
<span>
<span className="font-medium text-gray-600">Doctors (AC):</span>{" "}
{doctorHours?.enabled
? `${fmt(doctorHours.amStart)}${fmt(doctorHours.amEnd)}, ${fmt(doctorHours.pmStart)}${fmt(doctorHours.pmEnd)}`
: <span className="text-gray-400">Closed</span>}
</span>
<span>
<span className="font-medium text-gray-600">Hygienists (DF):</span>{" "}
{hygHours?.enabled
? `${fmt(hygHours.amStart)}${fmt(hygHours.amEnd)}, ${fmt(hygHours.pmStart)}${fmt(hygHours.pmEnd)}`
: <span className="text-gray-400">Closed</span>}
</span>
</>
)}
</div>
);
})()}
{/* Schedule Grid with Drag and Drop */}
<DndProvider backend={HTML5Backend}>
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[800px]">
<thead>
<tr>
<th className="p-2 border bg-gray-50 w-[100px]">Time</th>
{staffMembers.map((staff, index) => (
<th
key={staff.id}
className={`p-2 border bg-gray-50 ${staff.role === "doctor" ? "font-bold" : ""}`}
>
{editingLabelStaffId === String(staff.id) ? (
<input
className="w-16 text-center border rounded text-sm font-bold px-1"
defaultValue={columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
onBlur={(e) => 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
/>
) : (
<div
className="font-bold text-sm cursor-pointer hover:bg-gray-200 rounded px-1 inline-block"
title="Click to rename"
onClick={() => setEditingLabelStaffId(String(staff.id))}
>
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</div>
)}
<div className="text-xs text-gray-500">{staff.name}</div>
<div className="text-xs text-gray-400">{staff.role}</div>
</th>
))}
</tr>
</thead>
<tbody>
{timeSlots.map((timeSlot) => (
<tr key={timeSlot.time}>
<td className="border px-2 py-1 text-xs text-gray-600 font-medium">
{timeSlot.displayTime}
</td>
{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 (
<DroppableTimeSlot
key={`${timeSlot.time}-${staff.id}`}
timeSlot={timeSlot}
staffId={Number(staff.id)}
staffIndex={staffIndex}
appointment={apt}
staff={staff}
rowSpan={span}
/>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</DndProvider>
</div>
</div>
</div>
{/* Add/Edit Appointment Modal */}
<AddAppointmentModal
open={isAddModalOpen}
onOpenChange={(open) => {
setIsAddModalOpen(open);
if (!open) setNewApptPrefill(null);
}}
onSubmit={handleAppointmentSubmit}
isLoading={
createAppointmentMutation.isPending ||
updateAppointmentMutation.isPending
}
appointment={editingAppointment}
prefillData={editingAppointment ? null : newApptPrefill}
onDelete={handleDeleteAppointment}
/>
<DeleteConfirmationDialog
isOpen={confirmDeleteState.open}
onConfirm={handleConfirmDelete}
onCancel={() => setConfirmDeleteState({ open: false })}
entityName={String(confirmDeleteState.appointmentId)}
/>
{/* Outside-office-hours override dialog */}
{pendingOverride && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 className="text-base font-semibold mb-2">Outside Office Hours</h3>
<p className="text-sm text-gray-600 mb-5">
This time slot is outside the configured office hours. Do you want to schedule here anyway?
</p>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-sm border rounded hover:bg-gray-50"
onClick={() => setPendingOverride(null)}
>
Cancel
</button>
<button
className="px-4 py-2 text-sm bg-teal-600 text-white rounded hover:bg-teal-700"
onClick={() => {
if (pendingOverride.type === "move" && pendingOverride.appointmentId != null) {
handleMoveAppointment(pendingOverride.appointmentId, pendingOverride.timeSlot, pendingOverride.staffId);
} else {
handleCreateAppointmentAtSlot(pendingOverride.timeSlot, pendingOverride.staffId);
}
setPendingOverride(null);
}}
>
Schedule Anyway
</button>
</div>
</div>
</div>
)}
{/* Chat popup */}
{chatPatient && (
<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-lg h-[600px] flex flex-col overflow-hidden">
<MessageThread
patient={chatPatient}
appointmentInfo={chatAppointmentInfo}
onBack={() => { setChatPatient(null); setChatAppointmentInfo(undefined); }}
/>
</div>
</div>
)}
{/* Select Procedures modal — stays on appointments page */}
{isSelectProceduresOpen && selectProceduresPatientId !== null && (
<ClaimForm
patientId={selectProceduresPatientId}
appointmentId={selectProceduresAppointmentId ?? undefined}
proceduresOnly={true}
onClose={() => {
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 && (
<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>
) : aiClaimCdtClarification ? (
<div className="space-y-3">
<div>
<p className="text-sm font-semibold">{aiClaimCdtClarification.patientName}</p>
<p className="text-xs text-gray-500 mt-0.5">
Notes: <span className="italic">{aiClaimCdtClarification.notes}</span>
</p>
</div>
<p className="text-xs font-medium text-amber-700">
Unknown term{aiClaimCdtClarification.unknownPhrases.length > 1 ? "s" : ""} enter CDT code{aiClaimCdtClarification.unknownPhrases.length > 1 ? "s" : ""}:
</p>
{aiClaimCdtClarification.matchedSoFar.length > 0 && (
<div className="bg-teal-50 border border-teal-200 rounded p-2 space-y-0.5">
<p className="text-[10px] text-teal-700 font-medium uppercase tracking-wide">Already matched</p>
{aiClaimCdtClarification.matchedSoFar.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>
</p>
))}
</div>
)}
<div className="space-y-2">
{aiClaimCdtClarification.unknownPhrases.map((phrase) => (
<div key={phrase} className="flex items-center gap-2">
<span className="text-xs text-gray-700 font-medium shrink-0">"{phrase}" </span>
<input
type="text"
placeholder="D0272"
value={aiClaimCdtClarification.codeInputs[phrase] ?? ""}
onChange={(e) =>
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"
/>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<button
className="flex-1 flex items-center justify-center gap-1 text-xs h-8 px-3 rounded bg-amber-600 hover:bg-amber-700 text-white font-medium transition-colors disabled:opacity-50"
disabled={Object.values(aiClaimCdtClarification.codeInputs).some((v) => !v.trim())}
onClick={handleAiClaimCdtSubmit}
>
Save &amp; Retry
</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={() => { setAiClaimCdtClarification(null); 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={() => { setAiClaimCdtClarification(null); setAiClaimModalOpen(false); sessionStorage.removeItem("ai_claim_queue"); }}
>
Cancel All
</button>
</div>
</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>
);
}