import { useState, useEffect } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { addDays, startOfToday, addMinutes } from "date-fns"; import { parseLocalDate, formatLocalDate, formatLocalTime, } from "@/utils/dateUtils"; import { AddAppointmentModal } from "@/components/appointments/add-appointment-modal"; import { Button } from "@/components/ui/button"; import { Calendar as CalendarIcon, Plus, ChevronLeft, ChevronRight, Move, Trash2, CreditCard, ClipboardList, StickyNote, Shield, FileCheck, } 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, UpdateAppointment, } from "@repo/db/types"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; // 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; staffId: number; date: string | Date; startTime: string | Date; endTime: string | Date; status: string | null; type: string; } // Define a unique ID for the appointment context menu const APPOINTMENT_CONTEXT_MENU_ID = "appointment-context-menu"; // 🔑 exported base key export const QK_APPOINTMENTS_BASE = ["appointments", "day"] as const; // helper (optional) – mirrors the query key structure export const qkAppointmentsDay = (date: string) => [...QK_APPOINTMENTS_BASE, date] as const; export default function AppointmentsPage() { const { toast } = useToast(); const { user } = useAuth(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [calendarOpen, setCalendarOpen] = useState(false); const [editingAppointment, setEditingAppointment] = useState< Appointment | undefined >(undefined); const [selectedDate, setSelectedDate] = useState(startOfToday()); const [location] = useLocation(); const [confirmDeleteState, setConfirmDeleteState] = useState<{ open: boolean; appointmentId?: number; }>({ open: false }); const [, setLocation] = useLocation(); // 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 ?? []; // Staff memebers const { data: staffMembersRaw = [] as Staff[] } = useQuery({ queryKey: ["/api/staffs/"], queryFn: async () => { const res = await apiRequest("GET", "/api/staffs/"); return res.json(); }, enabled: !!user, }); const colors = [ "bg-blue-600", "bg-emerald-600", "bg-purple-600", "bg-pink-600", "bg-yellow-500", "bg-red-600", ]; // Assign colors cycling through the list const staffMembers = staffMembersRaw.map((staff, index) => ({ ...staff, color: colors[index % colors.length] || "bg-gray-400", })); // Generate time slots from 8:00 AM to 6:00 PM in 30-minute increments const timeSlots: TimeSlot[] = []; for (let hour = 8; hour <= 18; hour++) { for (let minute = 0; minute < 60; minute += 30) { const hour12 = hour > 12 ? hour - 12 : hour; const period = hour >= 12 ? "PM" : "AM"; const timeStr = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; const displayTime = `${hour12}:${minute.toString().padStart(2, "0")} ${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 ); return await res.json(); }, 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 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, staffId, status: apt.status ?? null, date: formatLocalDate(parseLocalDate(apt.date)), startTime: normalizedStart, endTime: normalizedEnd, } as ScheduledAppointment; return processed; }); // 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); // Update appointment data const { id, createdAt, ...sanitizedAppointment } = appointment; const updatedAppointment: UpdateAppointment = { ...sanitizedAppointment, startTime: newTimeSlot.time, // Already in HH:MM format endTime: endTime, // Already in HH:MM format notes: `Appointment with ${staff?.name}`, staffId: newStaffId, // Update staffId }; // 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 (
} // Type assertion to make TypeScript happy className={`${staff.color} border border-white shadow-md text-white rounded p-1 text-xs h-full overflow-hidden 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)} >
{appointment.patientName}
{appointment.type}
); } // Create a drop target for appointments function DroppableTimeSlot({ timeSlot, staffId, appointment, staff, }: { timeSlot: TimeSlot; staffId: number; appointment: ScheduledAppointment | undefined; staff: Staff; }) { const [{ isOver, canDrop }, drop] = useDrop(() => ({ accept: ItemTypes.APPOINTMENT, drop: (item: { id: number }) => { handleMoveAppointment(item.id, timeSlot, staffId); }, canDrop: (item: { id: number }) => { // Prevent dropping if there's already an appointment here return !appointment; }, collect: (monitor) => ({ isOver: !!monitor.isOver(), canDrop: !!monitor.canDrop(), }), })); return ( } key={`${timeSlot.time}-${staffId}`} className={`px-1 py-1 border relative h-14 ${isOver && canDrop ? "bg-green-100" : ""}`} > {appointment ? ( ) : ( )} ); } // ------------------- // 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 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}`); }; return (

Appointment Schedule

View and manage the dental practice schedule

{/* Context Menu */} { const fullAppointment = appointments.find( (a) => a.id === props.appointmentId ); if (fullAppointment) { handleEditAppointment(fullAppointment); } }} > Edit Appointment handleDeleteAppointment(props.appointmentId) } > Delete Appointment {/* Check Eligibility */} handleCheckEligibility(props.appointmentId)} > Eligibility Status {/* Check Eligibility */} handleCheckClaimStatus(props.appointmentId)} > Claim Status {/* Claims / PreAuth */} handleClaimsPreAuth(props.appointmentId)} > Claims / PreAuth {/* Payments */} handlePayments(props.appointmentId)}> Payments {/* Chart / Treatment Plan */} handleChartPlan(props.appointmentId)}> Chart / Treatment Plan {/* Clinic Notes */} handleClinicNotes(props.appointmentId)}> Clinic Notes {/* Main Content */}
{/* Schedule Grid */}

{formattedSelectedDate}

{/* Top button with popover calendar */}
{ if (date) setSelectedDate(date); }} onClose={() => setCalendarOpen(false)} />
{/* Schedule Grid with Drag and Drop */}
{staffMembers.map((staff) => ( ))} {timeSlots.map((timeSlot) => ( {staffMembers.map((staff) => ( ))} ))}
Time {staff.name}
{staff.role}
{timeSlot.displayTime}
{/* Add/Edit Appointment Modal */} setConfirmDeleteState({ open: false })} entityName={String(confirmDeleteState.appointmentId)} />
); }