import React, { useState, useMemo, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns'; import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments'; import { Appointment } from '../types'; import Portal from './Portal'; type ViewMode = 'day' | 'week' | 'month'; // 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`; }; // Constants for timeline rendering const PIXELS_PER_HOUR = 64; const PIXELS_PER_MINUTE = PIXELS_PER_HOUR / 60; interface ResourceCalendarProps { resourceId: string; resourceName: string; onClose: () => void; } const ResourceCalendar: React.FC = ({ resourceId, resourceName, onClose }) => { const { t } = useTranslation(); const [viewMode, setViewMode] = useState('day'); const [currentDate, setCurrentDate] = useState(new Date()); const timelineRef = useRef(null); const timeLabelsRef = useRef(null); // Drag state const [dragState, setDragState] = useState<{ appointmentId: string; startY: number; originalStartTime: Date; originalDuration: number; } | null>(null); const [dragPreview, setDragPreview] = useState(null); // Resize state const [resizeState, setResizeState] = useState<{ appointmentId: string; direction: 'top' | 'bottom'; startY: number; originalStartTime: Date; originalDuration: number; } | null>(null); const [resizePreview, setResizePreview] = useState<{ startTime: Date; duration: number } | null>(null); const updateMutation = useUpdateAppointment(); // Auto-scroll to current time or 8 AM when switching to day/week view useEffect(() => { if ((viewMode === 'day' || viewMode === 'week') && timelineRef.current) { const now = new Date(); const scrollToHour = isToday(currentDate) ? Math.max(now.getHours() - 1, 0) // Scroll to an hour before current time : 8; // Default to 8 AM for other days timelineRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR; // Sync time labels scroll if (timeLabelsRef.current) { timeLabelsRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR; } } }, [viewMode, currentDate]); // Sync scroll between timeline and time labels (for week view) useEffect(() => { const timeline = timelineRef.current; const timeLabels = timeLabelsRef.current; if (!timeline || !timeLabels) return; const handleTimelineScroll = () => { if (timeLabels) { timeLabels.scrollTop = timeline.scrollTop; } }; timeline.addEventListener('scroll', handleTimelineScroll); return () => timeline.removeEventListener('scroll', handleTimelineScroll); }, [viewMode]); // Helper to get Monday of the week containing the given date const getMonday = (date: Date) => { return startOfWeek(date, { weekStartsOn: 1 }); // 1 = Monday }; // Helper to get Friday of the week (4 days after Monday) const getFriday = (date: Date) => { return addDays(getMonday(date), 4); }; // Calculate date range based on view mode const dateRange = useMemo(() => { switch (viewMode) { case 'day': return { startDate: startOfDay(currentDate), endDate: addDays(startOfDay(currentDate), 1) }; case 'week': // Full week (Monday to Sunday) return { startDate: getMonday(currentDate), endDate: addDays(getMonday(currentDate), 7) }; case 'month': return { startDate: startOfMonth(currentDate), endDate: addDays(endOfMonth(currentDate), 1) }; } }, [viewMode, currentDate]); // Fetch appointments for this resource within the date range const { data: allAppointments = [], isLoading } = useAppointments({ resource: resourceId, ...dateRange }); // Filter appointments for this specific resource const appointments = useMemo(() => { const resourceIdStr = String(resourceId); return allAppointments.filter(apt => apt.resourceId === resourceIdStr); }, [allAppointments, resourceId]); const navigatePrevious = () => { switch (viewMode) { case 'day': setCurrentDate(addDays(currentDate, -1)); break; case 'week': setCurrentDate(addWeeks(currentDate, -1)); break; case 'month': setCurrentDate(addMonths(currentDate, -1)); break; } }; const navigateNext = () => { switch (viewMode) { case 'day': setCurrentDate(addDays(currentDate, 1)); break; case 'week': setCurrentDate(addWeeks(currentDate, 1)); break; case 'month': setCurrentDate(addMonths(currentDate, 1)); break; } }; const goToToday = () => { setCurrentDate(new Date()); }; const getTitle = () => { switch (viewMode) { case 'day': return format(currentDate, 'EEEE, MMMM d, yyyy'); case 'week': const weekStart = getMonday(currentDate); const weekEnd = addDays(weekStart, 6); // Sunday return `${format(weekStart, 'MMM d')} - ${format(weekEnd, 'MMM d, yyyy')}`; case 'month': return format(currentDate, 'MMMM yyyy'); } }; // Get appointments for a specific day const getAppointmentsForDay = (day: Date) => { return appointments.filter(apt => isSameDay(new Date(apt.startTime), day)); }; // Convert Y position to time const yToTime = (y: number, baseDate: Date): Date => { const minutes = Math.round((y / PIXELS_PER_MINUTE) / 15) * 15; // Snap to 15 min const result = new Date(baseDate); result.setHours(0, 0, 0, 0); result.setMinutes(minutes); return result; }; // Handle drag start const handleDragStart = (e: React.MouseEvent, apt: Appointment) => { e.preventDefault(); const rect = timelineRef.current?.getBoundingClientRect(); if (!rect) return; setDragState({ appointmentId: apt.id, startY: e.clientY, originalStartTime: new Date(apt.startTime), originalDuration: apt.durationMinutes, }); }; // Handle resize start const handleResizeStart = (e: React.MouseEvent, apt: Appointment, direction: 'top' | 'bottom') => { e.preventDefault(); e.stopPropagation(); setResizeState({ appointmentId: apt.id, direction, startY: e.clientY, originalStartTime: new Date(apt.startTime), originalDuration: apt.durationMinutes, }); }; // Mouse move handler for drag and resize useEffect(() => { if (!dragState && !resizeState) return; const handleMouseMove = (e: MouseEvent) => { if (dragState) { const deltaY = e.clientY - dragState.startY; const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15; const newStartTime = new Date(dragState.originalStartTime.getTime() + deltaMinutes * 60000); // Keep within same day const dayStart = startOfDay(dragState.originalStartTime); const dayEnd = endOfDay(dragState.originalStartTime); if (newStartTime >= dayStart && newStartTime <= dayEnd) { setDragPreview(newStartTime); } } if (resizeState) { const deltaY = e.clientY - resizeState.startY; const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15; if (resizeState.direction === 'bottom') { // Resize from bottom - change duration const newDuration = Math.max(15, resizeState.originalDuration + deltaMinutes); setResizePreview({ startTime: resizeState.originalStartTime, duration: newDuration, }); } else { // Resize from top - change start time and duration const newStartTime = new Date(resizeState.originalStartTime.getTime() + deltaMinutes * 60000); const newDuration = Math.max(15, resizeState.originalDuration - deltaMinutes); // Keep within same day const dayStart = startOfDay(resizeState.originalStartTime); if (newStartTime >= dayStart) { setResizePreview({ startTime: newStartTime, duration: newDuration, }); } } } }; const handleMouseUp = () => { if (dragState && dragPreview) { updateMutation.mutate({ id: dragState.appointmentId, updates: { startTime: dragPreview, durationMinutes: dragState.originalDuration, // Preserve duration when dragging } }); } if (resizeState && resizePreview) { updateMutation.mutate({ id: resizeState.appointmentId, updates: { startTime: resizePreview.startTime, durationMinutes: resizePreview.duration, } }); } setDragState(null); setDragPreview(null); setResizeState(null); setResizePreview(null); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [dragState, dragPreview, resizeState, resizePreview, updateMutation]); // Calculate lanes for overlapping appointments const calculateLanes = (appts: Appointment[]): Map => { const laneMap = new Map(); if (appts.length === 0) return laneMap; // Sort by start time const sorted = [...appts].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() ); // Get end time for an appointment const getEndTime = (apt: Appointment) => { return new Date(apt.startTime).getTime() + apt.durationMinutes * 60000; }; // Find overlapping groups const groups: Appointment[][] = []; let currentGroup: Appointment[] = []; let groupEndTime = 0; for (const apt of sorted) { const aptStart = new Date(apt.startTime).getTime(); const aptEnd = getEndTime(apt); if (currentGroup.length === 0 || aptStart < groupEndTime) { // Overlaps with current group currentGroup.push(apt); groupEndTime = Math.max(groupEndTime, aptEnd); } else { // Start new group if (currentGroup.length > 0) { groups.push(currentGroup); } currentGroup = [apt]; groupEndTime = aptEnd; } } if (currentGroup.length > 0) { groups.push(currentGroup); } // Assign lanes within each group for (const group of groups) { const totalLanes = group.length; // Sort by start time within group group.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); group.forEach((apt, index) => { laneMap.set(apt.id, { lane: index, totalLanes }); }); } return laneMap; }; const renderDayView = () => { const dayStart = startOfDay(currentDate); const hours = eachHourOfInterval({ start: dayStart, end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000) }); const dayAppointments = getAppointmentsForDay(currentDate); const laneAssignments = calculateLanes(dayAppointments); return (
{/* Hour grid lines */} {hours.map((hour) => (
{format(hour, 'h a')}
{/* Half-hour line */}
))} {/* Render appointments */} {dayAppointments.map((apt) => { const isDragging = dragState?.appointmentId === apt.id; const isResizing = resizeState?.appointmentId === apt.id; // Use preview values if dragging/resizing this appointment let displayStartTime = new Date(apt.startTime); let displayDuration = apt.durationMinutes; if (isDragging && dragPreview) { displayStartTime = dragPreview; } if (isResizing && resizePreview) { displayStartTime = resizePreview.startTime; displayDuration = resizePreview.duration; } const startHour = displayStartTime.getHours() + displayStartTime.getMinutes() / 60; const durationHours = displayDuration / 60; const top = startHour * PIXELS_PER_HOUR; const height = Math.max(durationHours * PIXELS_PER_HOUR, 30); // Get lane info for overlapping appointments const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 }; const widthPercent = 100 / laneInfo.totalLanes; const leftPercent = laneInfo.lane * widthPercent; return (
handleDragStart(e, apt)} > {/* Top resize handle */}
handleResizeStart(e, apt, 'top')} />
{apt.customerName}
{format(displayStartTime, 'h:mm a')} • {formatDuration(displayDuration)}
{/* Bottom resize handle */}
handleResizeStart(e, apt, 'bottom')} />
); })} {/* Current time indicator */} {isToday(currentDate) && (
)}
); }; const renderWeekView = () => { // Full week Monday to Sunday const days = eachDayOfInterval({ start: getMonday(currentDate), end: addDays(getMonday(currentDate), 6) }); const dayStart = startOfDay(days[0]); const hours = eachHourOfInterval({ start: dayStart, end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000) }); const DAY_COLUMN_WIDTH = 200; // pixels per day column return (
{/* Day headers - fixed at top */}
{/* Spacer for time column */}
{days.map((day) => (
{ setCurrentDate(day); setViewMode('day'); }} > {format(day, 'EEE, MMM d')}
))}
{/* Scrollable timeline grid */}
{/* Time labels - fixed left column */}
{hours.map((hour) => (
{format(hour, 'h a')}
))}
{/* Day columns with appointments - scrollable both ways */}
{days.map((day) => { const dayAppointments = getAppointmentsForDay(day); const laneAssignments = calculateLanes(dayAppointments); return (
{ setCurrentDate(day); setViewMode('day'); }} > {/* Hour grid lines */} {hours.map((hour) => (
))} {/* Appointments for this day */} {dayAppointments.map((apt) => { const aptStartTime = new Date(apt.startTime); const startHour = aptStartTime.getHours() + aptStartTime.getMinutes() / 60; const durationHours = apt.durationMinutes / 60; const top = startHour * PIXELS_PER_HOUR; const height = Math.max(durationHours * PIXELS_PER_HOUR, 24); const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 }; const widthPercent = 100 / laneInfo.totalLanes; const leftPercent = laneInfo.lane * widthPercent; return (
{ e.stopPropagation(); setCurrentDate(day); setViewMode('day'); }} >
{apt.customerName}
{format(aptStartTime, 'h:mm a')}
); })} {/* Current time indicator for today */} {isToday(day) && (
)}
); })}
); }; const renderMonthView = () => { const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(currentDate); const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); // Start padding from Monday (weekStartsOn: 1) const startDayOfWeek = getDay(monthStart); // Adjust for Monday start: if Sunday (0), it's 6 days from Monday; otherwise subtract 1 const paddingDays = Array(startDayOfWeek === 0 ? 6 : startDayOfWeek - 1).fill(null); return (
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => (
{day}
))} {paddingDays.map((_, index) => (
))} {days.map((day) => { const dayAppointments = getAppointmentsForDay(day); const dayOfWeek = getDay(day); const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; return (
{ // Drill down to week view showing the week containing this day setCurrentDate(day); setViewMode('week'); }} >
{format(day, 'd')}
{dayAppointments.length > 0 && (
{dayAppointments.length} appt{dayAppointments.length > 1 ? 's' : ''}
)}
); })}
); }; return (
{/* Header */}

{resourceName} Calendar

{viewMode === 'day' ? 'Drag to move, drag edges to resize' : 'Click a day to view details'}

{/* Toolbar */}
{getTitle()}
{/* View Mode Selector */}
{(['day', 'week', 'month'] as ViewMode[]).map((mode) => ( ))}
{/* Calendar Content */}
{viewMode === 'day' && renderDayView()} {viewMode === 'week' && renderWeekView()} {viewMode === 'month' && renderMonthView()}
{isLoading && (

{t('scheduler.loadingAppointments')}

)} {!isLoading && appointments.length === 0 && (

{t('scheduler.noAppointmentsScheduled')}

)}
); }; export default ResourceCalendar;