/** * Owner Scheduler - Horizontal timeline view for owner/manager/staff users */ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Appointment, AppointmentStatus, User, Business, Resource } from '../types'; import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight, Check, AlertTriangle } from 'lucide-react'; import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments'; import { useResources } from '../hooks/useResources'; import { useServices } from '../hooks/useServices'; import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket'; import Portal from '../components/Portal'; import EventAutomations from '../components/EventAutomations'; import { getOverQuotaResourceIds } from '../utils/quotaUtils'; // Time settings const START_HOUR = 0; // Midnight const END_HOUR = 24; // Midnight next day const PIXELS_PER_MINUTE = 2.5; const OVERLAY_HOUR_HEIGHT = 60; const OVERLAY_RESOURCE_WIDTH = 150; // Width of each resource column in month overlay const HEADER_HEIGHT = 48; const SIDEBAR_WIDTH = 250; // Format duration as hours and minutes when >= 60 min const formatDuration = (minutes: number): string => { if (minutes >= 60) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } return `${minutes} min`; }; // Layout settings const MIN_ROW_HEIGHT = 104; const EVENT_HEIGHT = 88; const EVENT_GAP = 8; interface OwnerSchedulerProps { user: User; business: Business; } const OwnerScheduler: React.FC = ({ user, business }) => { type ViewMode = 'day' | 'week' | 'month'; const [viewMode, setViewMode] = useState('day'); const [viewDate, setViewDate] = useState(new Date()); // Calculate date range for fetching appointments based on current view const dateRange = useMemo(() => { const getStartOfWeek = (date: Date): Date => { const d = new Date(date); const day = d.getDay(); d.setDate(d.getDate() - day); d.setHours(0, 0, 0, 0); return d; }; if (viewMode === 'day') { const start = new Date(viewDate); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); return { startDate: start, endDate: end }; } else if (viewMode === 'week') { const start = getStartOfWeek(viewDate); const end = new Date(start); end.setDate(end.getDate() + 7); return { startDate: start, endDate: end }; } else { // Month view const start = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1); const end = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1); return { startDate: start, endDate: end }; } }, [viewMode, viewDate]); // Fetch only appointments in the visible date range (plus pending ones) const { data: appointments = [] } = useAppointments(dateRange); const { data: resources = [] } = useResources(); const { data: services = [] } = useServices(); const updateMutation = useUpdateAppointment(); const deleteMutation = useDeleteAppointment(); // Calculate over-quota resources (will be auto-archived when grace period ends) const overQuotaResourceIds = useMemo( () => getOverQuotaResourceIds(resources as Resource[], user.quota_overages), [resources, user.quota_overages] ); // Connect to WebSocket for real-time updates useAppointmentWebSocket(); const [zoomLevel, setZoomLevel] = useState(1); const [draggedAppointmentId, setDraggedAppointmentId] = useState(null); const [dragOffsetMinutes, setDragOffsetMinutes] = useState(0); // Track where on appointment drag started const [previewState, setPreviewState] = useState<{ resourceId: string; startTime: Date; } | null>(null); const [resizeState, setResizeState] = useState<{ appointmentId: string; direction: 'start' | 'end'; startX: number; originalStart: Date; originalDuration: number; newStart?: Date; newDuration?: number; } | null>(null); const [selectedAppointment, setSelectedAppointment] = useState(null); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [monthDropTarget, setMonthDropTarget] = useState<{ date: Date; rect: DOMRect } | null>(null); const [overlayPreview, setOverlayPreview] = useState<{ resourceId: string; hour: number; minute: number } | null>(null); const overlayAutoScrollRef = useRef(null); const monthOverlayDelayRef = useRef(null); const pendingMonthDropRef = useRef<{ date: Date; rect: DOMRect } | null>(null); // State for editing appointments const [editDateTime, setEditDateTime] = useState(''); const [editResource, setEditResource] = useState(''); const [editDuration, setEditDuration] = useState(0); const [editStatus, setEditStatus] = useState('CONFIRMED'); // Filter state const [showFilterMenu, setShowFilterMenu] = useState(false); const [filterStatuses, setFilterStatuses] = useState>(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'])); const [filterResources, setFilterResources] = useState>(new Set()); // Empty means all const [filterServices, setFilterServices] = useState>(new Set()); // Empty means all const filterMenuRef = useRef(null); // Update edit state when selected appointment changes useEffect(() => { if (selectedAppointment) { // Format date in local time for datetime-local input (toISOString uses UTC) const date = new Date(selectedAppointment.startTime); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); setEditDateTime(`${year}-${month}-${day}T${hours}:${minutes}`); setEditResource(selectedAppointment.resourceId || ''); setEditDuration(selectedAppointment.durationMinutes); setEditStatus(selectedAppointment.status); } }, [selectedAppointment]); // Undo/Redo history type HistoryAction = { type: 'move' | 'resize'; appointmentId: string; before: { startTime: Date; resourceId: string | null; durationMinutes?: number }; after: { startTime: Date; resourceId: string | null; durationMinutes?: number }; }; const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const scrollContainerRef = useRef(null); const overlayScrollRef = useRef(null); // Ref for the MonthDropOverlay's scrollable area const overlayContainerRef = useRef(null); // Ref for the overlay container (for wheel events) const isOverOverlayRef = useRef(false); // Track if mouse is over the month drop overlay // Keyboard shortcuts for undo/redo useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [historyIndex, history]); // Handle wheel events on month drop overlay for horizontal scrolling // Use a callback ref pattern to attach the listener when the element is available const overlayWheelHandler = React.useCallback((e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); if (overlayScrollRef.current) { // Convert vertical scroll to horizontal: scroll up = scroll left, scroll down = scroll right overlayScrollRef.current.scrollLeft += e.deltaY; // Sync the header const header = overlayScrollRef.current.previousElementSibling as HTMLElement; if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft; } }, []); // Callback ref that attaches the wheel listener when the overlay mounts const overlayContainerCallbackRef = React.useCallback((node: HTMLDivElement | null) => { // Remove listener from previous node if any if (overlayContainerRef.current) { overlayContainerRef.current.removeEventListener('wheel', overlayWheelHandler); } // Store the new ref overlayContainerRef.current = node; // Add listener to new node if (node) { node.addEventListener('wheel', overlayWheelHandler, { passive: false }); } }, [overlayWheelHandler]); // Close filter menu when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) { setShowFilterMenu(false); } }; if (showFilterMenu) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [showFilterMenu]); // Filter toggle helpers const toggleStatusFilter = (status: AppointmentStatus) => { const newSet = new Set(filterStatuses); if (newSet.has(status)) { newSet.delete(status); } else { newSet.add(status); } setFilterStatuses(newSet); }; const toggleResourceFilter = (resourceId: string) => { const newSet = new Set(filterResources); if (newSet.has(resourceId)) { newSet.delete(resourceId); } else { newSet.add(resourceId); } setFilterResources(newSet); }; const toggleServiceFilter = (serviceId: string) => { const newSet = new Set(filterServices); if (newSet.has(serviceId)) { newSet.delete(serviceId); } else { newSet.add(serviceId); } setFilterServices(newSet); }; const clearAllFilters = () => { setFilterStatuses(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'])); setFilterResources(new Set()); setFilterServices(new Set()); }; const hasActiveFilters = filterStatuses.size < 5 || filterResources.size > 0 || filterServices.size > 0; // Scroll to current time on mount (centered in view) useEffect(() => { if (!scrollContainerRef.current) return; const now = new Date(); const today = new Date(viewDate); today.setHours(0, 0, 0, 0); const nowDay = new Date(now); nowDay.setHours(0, 0, 0, 0); // Only scroll if today is in the current view if (viewMode === 'day' && nowDay.getTime() !== today.getTime()) return; const container = scrollContainerRef.current; const containerWidth = container.clientWidth; // Calculate current time offset in pixels const startOfDay = new Date(now); startOfDay.setHours(START_HOUR, 0, 0, 0); const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60); const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE * zoomLevel; // Scroll so current time is centered const scrollPosition = currentTimeOffset - (containerWidth / 2); container.scrollLeft = Math.max(0, scrollPosition); }, []); const addToHistory = (action: HistoryAction) => { // Remove any history after current index (when doing new action after undo) const newHistory = history.slice(0, historyIndex + 1); newHistory.push(action); // Limit history to 50 actions if (newHistory.length > 50) { newHistory.shift(); } else { setHistoryIndex(historyIndex + 1); } setHistory(newHistory); }; const undo = () => { if (historyIndex < 0) return; const action = history[historyIndex]; const appointment = appointments.find(a => a.id === action.appointmentId); if (!appointment) return; // Revert to "before" state updateMutation.mutate({ id: action.appointmentId, updates: { startTime: action.before.startTime, resourceId: action.before.resourceId, ...(action.before.durationMinutes !== undefined && { durationMinutes: action.before.durationMinutes }) } }); setHistoryIndex(historyIndex - 1); }; const redo = () => { if (historyIndex >= history.length - 1) return; const action = history[historyIndex + 1]; const appointment = appointments.find(a => a.id === action.appointmentId); if (!appointment) return; // Apply "after" state updateMutation.mutate({ id: action.appointmentId, updates: { startTime: action.after.startTime, resourceId: action.after.resourceId, ...(action.after.durationMinutes !== undefined && { durationMinutes: action.after.durationMinutes }) } }); setHistoryIndex(historyIndex + 1); }; // Date navigation helpers const getStartOfWeek = (date: Date): Date => { const d = new Date(date); const day = d.getDay(); const diff = d.getDate() - day; // Sunday as start of week return new Date(d.setDate(diff)); }; const getStartOfMonth = (date: Date): Date => { return new Date(date.getFullYear(), date.getMonth(), 1); }; const getEndOfMonth = (date: Date): Date => { return new Date(date.getFullYear(), date.getMonth() + 1, 0); }; // Generate calendar grid data for month view const getMonthCalendarData = () => { const firstDay = getStartOfMonth(viewDate); const lastDay = getEndOfMonth(viewDate); const startDayOfWeek = firstDay.getDay(); // 0 = Sunday const daysInMonth = lastDay.getDate(); // Create array of week rows const weeks: (Date | null)[][] = []; let currentWeek: (Date | null)[] = []; // Add empty cells for days before the first of the month for (let i = 0; i < startDayOfWeek; i++) { currentWeek.push(null); } // Add all days of the month for (let day = 1; day <= daysInMonth; day++) { currentWeek.push(new Date(viewDate.getFullYear(), viewDate.getMonth(), day)); if (currentWeek.length === 7) { weeks.push(currentWeek); currentWeek = []; } } // Add empty cells for remaining days after the last of the month if (currentWeek.length > 0) { while (currentWeek.length < 7) { currentWeek.push(null); } weeks.push(currentWeek); } return weeks; }; // Get appointments for a specific day (for month view) const getAppointmentsForDay = (date: Date) => { const dayStart = new Date(date); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(date); dayEnd.setHours(23, 59, 59, 999); return filteredAppointments.filter(apt => { if (!apt.resourceId) return false; // Exclude pending const aptDate = new Date(apt.startTime); return aptDate >= dayStart && aptDate <= dayEnd; }).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); }; const navigateDate = (direction: 'prev' | 'next') => { const newDate = new Date(viewDate); if (viewMode === 'day') { newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1)); } else if (viewMode === 'week') { newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7)); } else if (viewMode === 'month') { newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1)); } setViewDate(newDate); }; const getDateRangeLabel = (): string => { if (viewMode === 'day') { return viewDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); } else if (viewMode === 'week') { const weekStart = getStartOfWeek(viewDate); const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 6); return `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; } else { return viewDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); } }; // Get the date range for filtering appointments const getDateRange = (): { start: Date; end: Date; days: Date[] } => { if (viewMode === 'day') { const start = new Date(viewDate); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); return { start, end, days: [start] }; } else if (viewMode === 'week') { const start = getStartOfWeek(viewDate); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 7); const days = Array.from({ length: 7 }, (_, i) => { const day = new Date(start); day.setDate(day.getDate() + i); return day; }); return { start, end, days }; } else { const start = getStartOfMonth(viewDate); start.setHours(0, 0, 0, 0); const end = new Date(getEndOfMonth(viewDate)); end.setDate(end.getDate() + 1); end.setHours(0, 0, 0, 0); const daysInMonth = end.getDate() - start.getDate(); const days = Array.from({ length: daysInMonth }, (_, i) => { const day = new Date(start); day.setDate(day.getDate() + i); return day; }); return { start, end, days }; } }; const handleResizeStart = ( e: React.MouseEvent, appointment: Appointment, direction: 'start' | 'end' ) => { e.preventDefault(); e.stopPropagation(); setIsResizing(true); setResizeState({ appointmentId: appointment.id, direction, startX: e.clientX, originalStart: new Date(appointment.startTime), originalDuration: appointment.durationMinutes, }); }; useEffect(() => { if (!resizeState) return; const handleMouseMove = (e: MouseEvent) => { const pixelDelta = e.clientX - resizeState.startX; const minuteDelta = pixelDelta / (PIXELS_PER_MINUTE * zoomLevel); const snappedMinutes = Math.round(minuteDelta / 15) * 15; if (snappedMinutes === 0 && resizeState.direction === 'end') return; const appointment = appointments.find(apt => apt.id === resizeState.appointmentId); if (!appointment) return; let newStart = new Date(resizeState.originalStart); let newDuration = resizeState.originalDuration; if (resizeState.direction === 'end') { newDuration = Math.max(15, resizeState.originalDuration + snappedMinutes); } else { if (resizeState.originalDuration - snappedMinutes >= 15) { newStart = new Date(resizeState.originalStart.getTime() + snappedMinutes * 60000); newDuration = resizeState.originalDuration - snappedMinutes; } } setResizeState(prev => prev ? { ...prev, newStart, newDuration } : null); }; const handleMouseUp = () => { if (resizeState && 'newStart' in resizeState && 'newDuration' in resizeState) { const appointment = appointments.find(a => a.id === resizeState.appointmentId); if (appointment) { // Add to history addToHistory({ type: 'resize', appointmentId: resizeState.appointmentId, before: { startTime: resizeState.originalStart, resourceId: appointment.resourceId, durationMinutes: resizeState.originalDuration }, after: { startTime: resizeState.newStart as Date, resourceId: appointment.resourceId, durationMinutes: resizeState.newDuration as number } }); updateMutation.mutate({ id: resizeState.appointmentId, updates: { startTime: resizeState.newStart as Date, durationMinutes: resizeState.newDuration as number } }); } } setResizeState(null); // Reset isResizing after a brief delay to prevent click handler from firing setTimeout(() => setIsResizing(false), 100); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [resizeState, zoomLevel, appointments, updateMutation]); const getOffset = (date: Date) => { const { days } = getDateRange(); // Find which day this appointment belongs to const appointmentDate = new Date(date); appointmentDate.setHours(0, 0, 0, 0); let dayIndex = 0; for (let i = 0; i < days.length; i++) { const day = new Date(days[i]); day.setHours(0, 0, 0, 0); if (day.getTime() === appointmentDate.getTime()) { dayIndex = i; break; } } // Calculate offset within the day const startOfDay = new Date(date); startOfDay.setHours(START_HOUR, 0, 0, 0); const diffMinutes = (date.getTime() - startOfDay.getTime()) / (1000 * 60); const offsetWithinDay = Math.max(0, diffMinutes * (PIXELS_PER_MINUTE * zoomLevel)); // Add the day offset const dayOffset = dayIndex * dayWidth; return dayOffset + offsetWithinDay; }; const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel); const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => { if (status === 'COMPLETED') return 'bg-green-100 border-green-500 text-green-900 dark:bg-green-900/50 dark:border-green-500 dark:text-green-200'; if (status === 'NO_SHOW') return 'bg-orange-100 border-orange-500 text-orange-900 dark:bg-orange-900/50 dark:border-orange-500 dark:text-orange-200'; if (status === 'CANCELLED') return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'; const now = new Date(); if (now > endTime) return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200'; if (now >= startTime && now <= endTime) return 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200'; return 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200'; }; // Simplified status colors for month view (no border classes) const getMonthStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => { if (status === 'COMPLETED') return 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800/50'; if (status === 'NO_SHOW') return 'bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-200 hover:bg-orange-200 dark:hover:bg-orange-800/50'; if (status === 'CANCELLED') return 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-75 hover:bg-gray-200 dark:hover:bg-gray-600'; const now = new Date(); if (now > endTime) return 'bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800/50'; if (now >= startTime && now <= endTime) return 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200 hover:bg-yellow-200 dark:hover:bg-yellow-800/50'; return 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800/50'; }; // Filter appointments by date range and user filters const { start: rangeStart, end: rangeEnd } = getDateRange(); const filteredAppointments = useMemo(() => { return appointments.filter(apt => { // Apply status filter if (!filterStatuses.has(apt.status)) return false; // Apply resource filter (empty set means show all) if (filterResources.size > 0 && apt.resourceId && !filterResources.has(apt.resourceId)) return false; // Apply service filter (empty set means show all) if (filterServices.size > 0 && !filterServices.has(apt.serviceId)) return false; // Always include pending requests (no resourceId) if they pass above filters if (!apt.resourceId) return true; // Filter scheduled appointments by date range const aptDate = new Date(apt.startTime); return aptDate >= rangeStart && aptDate < rangeEnd; }); }, [appointments, rangeStart, rangeEnd, filterStatuses, filterResources, filterServices]); const resourceLayouts = useMemo(() => { return resources.map(resource => { const allResourceApps = filteredAppointments.filter(a => a.resourceId === resource.id); const layoutApps = allResourceApps.filter(a => a.id !== draggedAppointmentId); // Add preview for dragged appointment if (previewState && previewState.resourceId === resource.id && draggedAppointmentId) { const original = filteredAppointments.find(a => a.id === draggedAppointmentId); if (original) { layoutApps.push({ ...original, startTime: previewState.startTime, id: 'PREVIEW' }); } } // Apply resize state to appointments for live preview const layoutAppsWithResize = layoutApps.map(apt => { if (resizeState && apt.id === resizeState.appointmentId && resizeState.newStart && resizeState.newDuration) { return { ...apt, startTime: resizeState.newStart, durationMinutes: resizeState.newDuration }; } return apt; }); layoutAppsWithResize.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes); const lanes: number[] = []; const visibleAppointments = layoutAppsWithResize.map(apt => { const start = new Date(apt.startTime).getTime(); const end = start + apt.durationMinutes * 60000; let laneIndex = -1; for (let i = 0; i < lanes.length; i++) { if (lanes[i] <= start) { laneIndex = i; lanes[i] = end; break; } } if (laneIndex === -1) { lanes.push(end); laneIndex = lanes.length - 1; } return { ...apt, laneIndex }; }); const laneCount = Math.max(1, lanes.length); const requiredHeight = Math.max(MIN_ROW_HEIGHT, (laneCount * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP); const finalAppointments = [...visibleAppointments, ...allResourceApps.filter(a => a.id === draggedAppointmentId).map(a => ({ ...a, laneIndex: 0 }))]; return { resource, height: requiredHeight, appointments: finalAppointments, laneCount }; }); }, [filteredAppointments, draggedAppointmentId, previewState, resources, resizeState]); const handleDragStart = (e: React.DragEvent, appointmentId: string) => { if (resizeState) return e.preventDefault(); setIsDragging(true); e.dataTransfer.setData('appointmentId', appointmentId); e.dataTransfer.effectAllowed = 'move'; // In month view, we don't track minute offset if (viewMode === 'month') { setDragOffsetMinutes(0); } else { // Calculate where on the appointment the drag started (relative to appointment, not timeline) const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); const offsetX = e.clientX - rect.left; // Just the offset within the appointment itself const offsetMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15; setDragOffsetMinutes(offsetMinutes); } setTimeout(() => setDraggedAppointmentId(appointmentId), 0); }; const handleDragEnd = () => { setDraggedAppointmentId(null); setPreviewState(null); setMonthDropTarget(null); setOverlayPreview(null); isOverOverlayRef.current = false; pendingMonthDropRef.current = null; // Clear any pending overlay delay timeout if (monthOverlayDelayRef.current) { clearTimeout(monthOverlayDelayRef.current); monthOverlayDelayRef.current = null; } // Clear any auto-scroll interval if (overlayAutoScrollRef.current) { clearInterval(overlayAutoScrollRef.current); overlayAutoScrollRef.current = null; } // Reset isDragging after a short delay to allow click detection setTimeout(() => setIsDragging(false), 100); }; const handleMonthCellDragOver = (e: React.DragEvent, date: Date) => { if (!draggedAppointmentId) return; // Don't update target if mouse is over the overlay if (isOverOverlayRef.current) return; e.preventDefault(); e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); // If we're already showing the overlay for this date, do nothing if (monthDropTarget && monthDropTarget.date.getTime() === date.getTime()) { return; } // If we moved to a different date, cancel any pending timeout and clear overlay if (pendingMonthDropRef.current && pendingMonthDropRef.current.date.getTime() !== date.getTime()) { if (monthOverlayDelayRef.current) { clearTimeout(monthOverlayDelayRef.current); monthOverlayDelayRef.current = null; } setMonthDropTarget(null); } // Store the pending drop target pendingMonthDropRef.current = { date, rect }; // Start a new timeout if not already pending for this date if (!monthOverlayDelayRef.current) { monthOverlayDelayRef.current = setTimeout(() => { if (pendingMonthDropRef.current) { setMonthDropTarget(pendingMonthDropRef.current); } monthOverlayDelayRef.current = null; }, 1000); // 1 second delay } }; const handleMonthTimeDrop = (e: React.DragEvent, targetHour: number, targetMinute: number, targetResourceId?: string) => { e.preventDefault(); e.stopPropagation(); if (!draggedAppointmentId || !monthDropTarget) return; const appointment = appointments.find(a => a.id === draggedAppointmentId); if (appointment) { const newStartTime = new Date(monthDropTarget.date); newStartTime.setHours(targetHour, targetMinute, 0, 0); // Use target resource if provided, otherwise keep existing const newResourceId = targetResourceId !== undefined ? targetResourceId : appointment.resourceId; // Add to history addToHistory({ type: 'move', appointmentId: appointment.id, before: { startTime: new Date(appointment.startTime), resourceId: appointment.resourceId, durationMinutes: appointment.durationMinutes }, after: { startTime: newStartTime, resourceId: newResourceId, durationMinutes: appointment.durationMinutes } }); updateMutation.mutate({ id: appointment.id, updates: { startTime: newStartTime, resourceId: newResourceId, durationMinutes: appointment.durationMinutes // Required to calculate end_time } }); } setMonthDropTarget(null); setDraggedAppointmentId(null); setOverlayPreview(null); isOverOverlayRef.current = false; }; const handleOverlayAutoScroll = (e: React.DragEvent) => { if (!overlayScrollRef.current) return; const rect = overlayScrollRef.current.getBoundingClientRect(); const mouseX = e.clientX; const scrollThreshold = 50; // pixels from edge to start scrolling const scrollSpeed = 8; // pixels per scroll step // Clear any existing auto-scroll if (overlayAutoScrollRef.current) { clearInterval(overlayAutoScrollRef.current); overlayAutoScrollRef.current = null; } // Start auto-scrolling if near left or right edge if (mouseX < rect.left + scrollThreshold && overlayScrollRef.current.scrollLeft > 0) { // Auto-scroll left overlayAutoScrollRef.current = setInterval(() => { if (overlayScrollRef.current) { overlayScrollRef.current.scrollLeft -= scrollSpeed; // Sync header const header = overlayScrollRef.current.previousElementSibling as HTMLElement; if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft; } }, 16); } else if (mouseX > rect.right - scrollThreshold && overlayScrollRef.current.scrollLeft < overlayScrollRef.current.scrollWidth - overlayScrollRef.current.clientWidth) { // Auto-scroll right overlayAutoScrollRef.current = setInterval(() => { if (overlayScrollRef.current) { overlayScrollRef.current.scrollLeft += scrollSpeed; // Sync header const header = overlayScrollRef.current.previousElementSibling as HTMLElement; if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft; } }, 16); } }; const handleAppointmentClick = (appointment: Appointment) => { // Only open modal if we didn't actually drag or resize if (!isDragging && !isResizing) { setSelectedAppointment(appointment); } }; const handleSaveAppointment = () => { if (!selectedAppointment) return; // Validate duration is at least 15 minutes const validDuration = editDuration >= 15 ? editDuration : 15; const updates: any = { startTime: new Date(editDateTime), durationMinutes: validDuration, status: editStatus, }; if (editResource) { updates.resourceId = editResource; } updateMutation.mutate({ id: selectedAppointment.id, updates }); setSelectedAppointment(null); }; const handleTimelineDragOver = (e: React.DragEvent) => { if (resizeState) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (!scrollContainerRef.current || !draggedAppointmentId) return; const container = scrollContainerRef.current; const rect = container.getBoundingClientRect(); const offsetX = e.clientX - rect.left + container.scrollLeft; const offsetY = e.clientY - rect.top + container.scrollTop - HEADER_HEIGHT; if (offsetY < 0) return; let targetResourceId: string | null = null; for (let i = 0, currentTop = 0; i < resourceLayouts.length; i++) { if (offsetY >= currentTop && offsetY < currentTop + resourceLayouts[i].height) { targetResourceId = resourceLayouts[i].resource.id; break; } currentTop += resourceLayouts[i].height; } if (!targetResourceId) return; // Calculate new start time, accounting for where on the appointment the drag started const mouseMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15; const newStartMinutes = mouseMinutes - dragOffsetMinutes; const newStartTime = new Date(viewDate); newStartTime.setHours(START_HOUR, 0, 0, 0); newStartTime.setTime(newStartTime.getTime() + newStartMinutes * 60000); if (!previewState || previewState.resourceId !== targetResourceId || previewState.startTime.getTime() !== newStartTime.getTime()) { setPreviewState({ resourceId: targetResourceId, startTime: newStartTime }); } }; const handleTimelineDrop = (e: React.DragEvent) => { e.preventDefault(); if (resizeState) return; const appointmentId = e.dataTransfer.getData('appointmentId'); if (appointmentId && previewState) { const appointment = appointments.find(a => a.id === appointmentId); if (appointment) { // Add to history addToHistory({ type: 'move', appointmentId, before: { startTime: new Date(appointment.startTime), resourceId: appointment.resourceId, durationMinutes: appointment.durationMinutes }, after: { startTime: previewState.startTime, resourceId: previewState.resourceId, durationMinutes: appointment.durationMinutes } }); updateMutation.mutate({ id: appointmentId, updates: { startTime: previewState.startTime, durationMinutes: appointment.durationMinutes, resourceId: previewState.resourceId, status: appointment.status === 'PENDING' ? 'CONFIRMED' : appointment.status } }); } } setDraggedAppointmentId(null); setPreviewState(null); }; const handleDropToPending = (e: React.DragEvent) => { e.preventDefault(); const appointmentId = e.dataTransfer.getData('appointmentId'); if (appointmentId) { updateMutation.mutate({ id: appointmentId, updates: { resourceId: null, status: 'PENDING' } }); } setDraggedAppointmentId(null); setPreviewState(null); }; const handleDropToArchive = (e: React.DragEvent) => { e.preventDefault(); const appointmentId = e.dataTransfer.getData('appointmentId'); if (appointmentId) { deleteMutation.mutate(appointmentId); } setDraggedAppointmentId(null); setPreviewState(null); }; const handleSidebarDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (previewState) setPreviewState(null); }; const { days } = getDateRange(); const dayWidth = (END_HOUR - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel); const timelineWidth = dayWidth * days.length; const timeMarkers = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i); const pendingAppointments = filteredAppointments.filter(a => !a.resourceId); return (
{/* Date Navigation */}
{getDateRangeLabel()}
{/* View Mode Switcher */}
{viewMode !== 'month' && (
Zoom
)}
{/* Status Legend */}
Status:
Upcoming
In Progress
Overdue
Completed
Cancelled
{/* Filter Dropdown */}
{showFilterMenu && (

Filters

{hasActiveFilters && ( )}
{/* Status Filter */}

Status

{(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as AppointmentStatus[]).map(status => (
toggleStatusFilter(status)} className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded" >
{filterStatuses.has(status) && }
{status.toLowerCase().replace('_', ' ')}
))}
{/* Resource Filter */}

Resources

{resources.map(resource => (
toggleResourceFilter(resource.id)} className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded" >
{(filterResources.size === 0 || filterResources.has(resource.id)) && }
{resource.name} {resource.type.toLowerCase()}
))}
{/* Service Filter */}

Services

{services.map(service => (
toggleServiceFilter(service.id)} className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded" >
{(filterServices.size === 0 || filterServices.has(service.id)) && }
{service.name} {service.durationMinutes}min
))}
)}
{/* Month View - Calendar Grid */} {viewMode === 'month' && (
{/* Pending Sidebar for Month View */}

Pending Requests ({pendingAppointments.length})

{pendingAppointments.length === 0 && (
No pending requests
)} {pendingAppointments.map(apt => { const service = services.find(s => s.id === apt.serviceId); return (
handleAppointmentClick(apt)} >

{apt.customerName}

{service?.name}

{formatDuration(apt.durationMinutes)}
); })}
{/* Calendar Grid */}
{/* Day headers */}
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
{day}
))}
{/* Calendar weeks */}
{getMonthCalendarData().flat().map((date, index) => { const isToday = date && new Date().toDateString() === date.toDateString(); const dayAppointments = date ? getAppointmentsForDay(date) : []; const displayedAppointments = dayAppointments.slice(0, 3); const remainingCount = dayAppointments.length - 3; return (
{ if (date) { setViewDate(date); setViewMode('day'); } }} onDragOver={(e) => date && handleMonthCellDragOver(e, date)} > {date && ( <>
{date.getDate()}
{displayedAppointments.map(apt => { const service = services.find(s => s.id === apt.serviceId); const resource = resources.find(r => r.id === apt.resourceId); const startTime = new Date(apt.startTime); const isDragged = apt.id === draggedAppointmentId; const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); return (
handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={(e) => { e.stopPropagation(); handleAppointmentClick(apt); }} title={`${apt.customerName} - ${service?.name} with ${resource?.name}`} > {startTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} {' '}{apt.customerName}
); })} {remainingCount > 0 && (
+{remainingCount} more
)}
)}
); })}
)} {/* Month View Drop Overlay - Mini Day Scheduler with Resource Rows */} {monthDropTarget && draggedAppointmentId && (() => { // Pre-calculate resource layouts with lanes for this day const dayStart = new Date(monthDropTarget.date); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(dayStart); dayEnd.setHours(23, 59, 59, 999); const OVERLAY_ROW_HEIGHT = 50; // Height per lane in overlay const OVERLAY_LANE_GAP = 2; const OVERLAY_PIXELS_PER_MINUTE = 1.5; const overlayTimelineWidth = (END_HOUR - START_HOUR) * 60 * OVERLAY_PIXELS_PER_MINUTE; // Get the dragged appointment for preview calculations const draggedApt = appointments.find(a => a.id === draggedAppointmentId); const overlayResourceLayouts = resources.map(resource => { // Get existing appointments for this resource on this day (excluding the dragged one) let resourceApps = appointments.filter(apt => { if (apt.resourceId !== resource.id || apt.id === draggedAppointmentId) return false; const t = new Date(apt.startTime); return t >= dayStart && t <= dayEnd; }); // Add preview appointment if hovering over this resource if (overlayPreview?.resourceId === resource.id && draggedApt) { const previewStartMinutes = (overlayPreview.hour - START_HOUR) * 60 + overlayPreview.minute; resourceApps = [...resourceApps, { ...draggedApt, id: 'PREVIEW', startTime: new Date(dayStart.getTime() + previewStartMinutes * 60000), }]; } // Sort by start time, then by duration (longer first) resourceApps.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes); // Calculate lanes (respecting maxConcurrentEvents) const lanes: number[] = []; const maxLanes = resource.maxConcurrentEvents === 0 ? Infinity : resource.maxConcurrentEvents; const laidOutApps = resourceApps.map(apt => { const start = new Date(apt.startTime); const startMinutes = (start.getHours() - START_HOUR) * 60 + start.getMinutes(); const endMinutes = startMinutes + apt.durationMinutes; let laneIndex = -1; for (let i = 0; i < Math.min(lanes.length, maxLanes); i++) { if (lanes[i] <= startMinutes) { laneIndex = i; lanes[i] = endMinutes; break; } } if (laneIndex === -1 && lanes.length < maxLanes) { lanes.push(endMinutes); laneIndex = lanes.length - 1; } else if (laneIndex === -1) { laneIndex = Math.min(lanes.length - 1, maxLanes - 1); } return { ...apt, laneIndex, startMinutes, endMinutes, isPreview: apt.id === 'PREVIEW' }; }); const laneCount = Math.max(1, Math.min(lanes.length, maxLanes === Infinity ? lanes.length : maxLanes)); const rowHeight = laneCount * OVERLAY_ROW_HEIGHT + OVERLAY_LANE_GAP; return { resource, appointments: laidOutApps, laneCount, rowHeight }; }); const totalRowsHeight = overlayResourceLayouts.reduce((sum, r) => sum + r.rowHeight, 0); return (
{ e.preventDefault(); e.stopPropagation(); }} onDragEnter={() => { isOverOverlayRef.current = true; }} onDragLeave={(e) => { // Only set to false if we're actually leaving the overlay (not entering a child) const rect = e.currentTarget.getBoundingClientRect(); if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) { isOverOverlayRef.current = false; } }} > {/* Header */}
{monthDropTarget.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
Drop on resource row
{/* Main content area */}
{/* Resource names sidebar - fixed */}
{/* Corner cell above resource names */}
{/* Resource names - synced scroll */}
{ if (el && overlayScrollRef.current) { el.scrollTop = overlayScrollRef.current.scrollTop; } }}> {overlayResourceLayouts.map(layout => (
{layout.resource.name} {layout.laneCount > 1 && ( {layout.laneCount} lanes )}
))}
{/* Timeline area - scrollable both ways */}
{/* Time header - horizontal scroll only */}
{Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => (
{hour === 0 ? '12a' : hour === 12 ? '12p' : hour > 12 ? `${hour - 12}p` : `${hour}a`}
))}
{/* Timeline grid with appointments */}
{ e.preventDefault(); handleOverlayAutoScroll(e); }} onScroll={(e) => { // Sync horizontal scroll with header const header = e.currentTarget.previousElementSibling as HTMLElement; if (header) header.scrollLeft = e.currentTarget.scrollLeft; }}>
{overlayResourceLayouts.map((layout) => (
{/* Hour grid lines */} {Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => ( {/* Hour line */}
{/* Half-hour line */}
))} {/* Drop zones for each half-hour */} {Array.from({ length: (END_HOUR - START_HOUR) * 2 }, (_, i) => { const hour = START_HOUR + Math.floor(i / 2); const minute = (i % 2) * 30; return (
handleMonthTimeDrop(e, hour, minute, layout.resource.id)} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; // Set preview state if (!overlayPreview || overlayPreview.resourceId !== layout.resource.id || overlayPreview.hour !== hour || overlayPreview.minute !== minute) { setOverlayPreview({ resourceId: layout.resource.id, hour, minute }); } }} onDragLeave={() => { // Only clear if leaving this specific zone if (overlayPreview?.resourceId === layout.resource.id && overlayPreview?.hour === hour && overlayPreview?.minute === minute) { // Don't clear immediately - let dragover on another zone set it } }} >
); })} {/* Appointments (including preview) */} {layout.appointments.map(apt => { const left = apt.startMinutes * OVERLAY_PIXELS_PER_MINUTE; const width = apt.durationMinutes * OVERLAY_PIXELS_PER_MINUTE; const top = apt.laneIndex * OVERLAY_ROW_HEIGHT + OVERLAY_LANE_GAP; const service = services.find(s => s.id === apt.serviceId); const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const isPreview = apt.isPreview; return (
{apt.customerName}
{service?.name}
); })}
))}
); })()} {/* Day/Week View - Timeline */} {viewMode !== 'month' && (
Resources
{resourceLayouts.map(layout => { const isOverQuota = overQuotaResourceIds.has(layout.resource.id); return (
{isOverQuota ? : }

{layout.resource.name}

{layout.resource.type.toLowerCase()} {layout.laneCount > 1 && {layout.laneCount} lanes} {isOverQuota && Over quota}

); })}

Pending Requests ({pendingAppointments.length})

{pendingAppointments.length === 0 && !draggedAppointmentId && (
No pending requests
)} {draggedAppointmentId && (
Drop here to unassign
)} {pendingAppointments.map(apt => { const service = services.find(s => s.id === apt.serviceId); return (
handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)} >

{apt.customerName}

{service?.name}

{formatDuration(apt.durationMinutes)}
) })}
Drop here to archive
{/* Timeline Header */}
{viewMode !== 'day' && (
{days.map((day, dayIndex) => (
{day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
))}
)}
{days.map((day, dayIndex) => (
{timeMarkers.map(hour => (
{hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
))}
))}
{/* Current time indicator - only show if current day is in view */} {days.some(day => { const today = new Date(); const dayDate = new Date(day); return today.toDateString() === dayDate.toDateString(); }) && (
)}
{/* Vertical grid lines for each day */}
{days.map((day, dayIndex) => ( {timeMarkers.map(hour => (
))}
))}
{resourceLayouts.map(layout => { const isResourceOverQuota = overQuotaResourceIds.has(layout.resource.id); return (
{layout.appointments.map(apt => { const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP; const service = services.find(s => s.id === apt.serviceId); return (
handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}> {!isPreview && (<>
handleResizeStart(e, apt, 'start')} />
handleResizeStart(e, apt, 'end')} />)}
{apt.customerName}
{service?.name}
{apt.status === 'COMPLETED' ? : }{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{formatDuration(apt.durationMinutes)}
); })}
); })}
)} {/* Appointment Detail/Edit Modal */} {selectedAppointment && (
setSelectedAppointment(null)}>
e.stopPropagation()}>

{!selectedAppointment.resourceId ? 'Schedule Appointment' : 'Edit Appointment'}

{/* Customer Info */}

Customer

{selectedAppointment.customerName}

{selectedAppointment.customerEmail && (
{selectedAppointment.customerEmail}
)} {selectedAppointment.customerPhone && (
{selectedAppointment.customerPhone}
)}
{/* Service & Status */}

Service

{services.find(s => s.id === selectedAppointment.serviceId)?.name}

{/* Editable Fields */}

Schedule Details

{/* Date & Time Picker */}
setEditDateTime(e.target.value)} className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" />
{/* Resource Selector */}
{/* Duration Input */}
{ const value = parseInt(e.target.value); setEditDuration(value >= 15 ? value : 15); }} className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" />
{/* Notes */} {selectedAppointment.notes && (

Notes

{selectedAppointment.notes}

)} {/* Automations - only show for saved appointments */} {selectedAppointment.id && (
)} {/* Action Buttons */}
)}
); }; export default OwnerScheduler;