Files
DentalManagementMH06/apps/Frontend/src/pages/appointments-page.tsx
Gitead fea0dd4d59 feat: extend schedule to 9 PM and make appointments span multiple rows by duration
- Time slots now run 8:00 AM – 9:00 PM (was 8:00 AM – 6:00 PM)
- Appointments visually span the correct number of 15-min rows based on startTime/endTime using HTML rowSpan
- Covered rows are skipped so the grid layout stays consistent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:17:00 -04:00

1708 lines
60 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 } 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 { Button } from "@/components/ui/button";
import {
Calendar as CalendarIcon,
Plus,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Move,
Trash2,
CreditCard,
ClipboardList,
StickyNote,
Shield,
FileCheck,
LoaderCircleIcon,
Stethoscope,
Download,
} 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 { 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";
// 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;
staffId: number;
date: string | Date;
startTime: string | Date;
endTime: string | Date;
status: string | null;
type: 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";
const isMassHealth = apt.patientInsuranceProvider?.toLowerCase().includes("masshealth");
if (apt.eligibilityStatus === "UNKNOWN" && isMassHealth) {
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 [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
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 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 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);
// 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(() => {
// Parse URL search params to check for newPatient
const params = new URLSearchParams(window.location.search);
const newPatientId = params.get("newPatient");
if (newPatientId) {
const patientId = parseInt(newPatientId);
// Choose first available staff safely (fallback to 1 if none)
const firstStaff =
staffMembers && staffMembers.length > 0 ? staffMembers[0] : undefined;
const staffId = firstStaff ? Number(firstStaff.id) : 1;
// Find first time slot today (9:00 AM is a common starting time)
const defaultTimeSlot =
timeSlots.find((slot) => slot.time === "09:00") || timeSlots[0];
if (!defaultTimeSlot) {
toast({
title: "Unable to schedule",
description:
"No available time slots to schedule the new patient right now.",
variant: "destructive",
});
return;
}
// Merge any existing "newAppointmentData" with the patient info BEFORE opening modal
try {
const existingRaw = sessionStorage.getItem("newAppointmentData");
const existing = existingRaw ? JSON.parse(existingRaw) : {};
const newAppointmentData = {
...existing,
patientId: patientId,
};
sessionStorage.setItem(
"newAppointmentData",
JSON.stringify(newAppointmentData)
);
} catch (err) {
// If sessionStorage parsing fails, overwrite with a fresh object
sessionStorage.setItem(
"newAppointmentData",
JSON.stringify({ patientId: patientId })
);
}
// Open/create the appointment modal (will read sessionStorage in the modal)
handleCreateAppointmentAtSlot(defaultTimeSlot, Number(staffId));
// Remove the query param from the URL so this doesn't re-run on navigation/refresh
params.delete("newPatient");
const newSearch = params.toString();
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ""}${window.location.hash || ""}`;
window.history.replaceState({}, "", newUrl);
}
}, [location]);
// 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,
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));
};
// 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 = getSlotSpan(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
) => {
// Calculate end time (30 minutes after start time)
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")}`;
// Find staff member
const staff = staffMembers.find((s) => Number(s.id) === Number(staffId));
// Try to read any existing prefill data (may include patientId from the URL handler)
let existingStored: any = null;
try {
const raw = sessionStorage.getItem("newAppointmentData");
existingStored = raw ? JSON.parse(raw) : null;
} catch (e) {
// ignore parse errors and treat as no existing stored data
existingStored = null;
}
// Build the prefill appointment object and merge existing stored data
const newAppointment = {
// base defaults
date: formatLocalDate(selectedDate),
startTime: timeSlot.time, // This is in "HH:MM" format
endTime: endTime,
type: staff?.role === "doctor" ? "checkup" : "cleaning",
status: "scheduled",
title: `Appointment with ${staff?.name}`,
notes: `Appointment with ${staff?.name}`,
staff: Number(staffId), // consistent field name that matches update mutation
// if existingStored has patientId (or other fields) merge them below
...(existingStored || {}),
};
// Ensure explicit values from this function override stale values from storage
// (for example, prefer current slot and staff)
const mergedAppointment = {
...newAppointment,
date: newAppointment.date,
startTime: newAppointment.startTime,
endTime: newAppointment.endTime,
staff: Number(staffId),
};
// Persist merged prefill so the modal/form can read it
try {
sessionStorage.setItem(
"newAppointmentData",
JSON.stringify(mergedAppointment)
);
} catch (e) {
// ignore sessionStorage write failures
console.error("Failed to write newAppointmentData to sessionStorage", e);
}
// For new appointments, set editingAppointment to undefined
setEditingAppointment(undefined);
// Open modal
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;
// Calculate new end time (30 minutes from start)
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, 30);
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);
// Send only the real DB fields — the appointment object may contain computed
// fields (hasProcedures, hasClaimWithNumber, etc.) that the Zod schema rejects
const apt = appointment as any;
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: `Appointment with ${staff?.name}`,
};
// Call update mutation
updateAppointmentMutation.mutate({
id: appointmentId,
appointment: updatedAppointment,
});
};
// 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(),
}),
}));
return (
<div
ref={drag as unknown as React.RefObject<HTMLDivElement>} // Type assertion to make TypeScript happy
className={`${appointmentCardColor(appointment)} border shadow-md rounded p-1 text-xs h-full overflow-visible cursor-move relative ${
isDragging ? "opacity-50" : "opacity-100"
}`}
style={{ fontWeight: 500 }}
onClick={(e) => {
// Only allow edit on click if we're not dragging
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" // ensure tooltip works
size={30} // bump size up from 10 → 14
/>
<div className="font-bold truncate flex items-center gap-1">
<Move className="h-3 w-3" />
{appointment.patientName}
</div>
<div className="truncate">{appointment.type}</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) => {
setLocation(
`/insurance-status?appointmentId=${appointmentId}&action=eligibility`
);
};
const handleCheckClaimStatus = (appointmentId: number) => {
setLocation(
`/insurance-status?appointmentId=${appointmentId}&action=claim`
);
};
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 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 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);
}
};
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>
{/* Text Reminder for Column section */}
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
<Button
disabled={true}
size="sm"
>
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>
{/* 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>
{/* Check Eligibility */}
<Item
onClick={({ props }) => handleCheckEligibility(props.appointmentId)}
>
<span className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Eligibility Status
</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>
{/* 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>
{/* Claim Status */}
<Item
onClick={({ props }) => handleCheckClaimStatus(props.appointmentId)}
>
<span className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Claim Status
</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="rounded-full w-auto px-3 font-semibold text-sm"
>
{formattedSelectedDate}
</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>
{/* 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 ? getSlotSpan(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={setIsAddModalOpen}
onSubmit={handleAppointmentSubmit}
isLoading={
createAppointmentMutation.isPending ||
updateAppointmentMutation.isPending
}
appointment={editingAppointment}
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>
)}
{/* 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={() => {}}
/>
)}
</div>
);
}