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>
2562 lines
99 KiB
TypeScript
Executable File
2562 lines
99 KiB
TypeScript
Executable File
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' })} · {`${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 (A–C):</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 (D–F):</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 & 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 & 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>
|
||
);
|
||
}
|