import React, { useState, useMemo, useRef, useEffect } from 'react'; import { DndContext, DragEndEvent, useSensor, useSensors, PointerSensor, DragOverlay } from '@dnd-kit/core'; import { addMinutes, startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, eachDayOfInterval, format, isSameDay } from 'date-fns'; import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Filter, Calendar as CalendarIcon, Undo, Redo, Clock, GripVertical } from 'lucide-react'; import clsx from 'clsx'; import TimelineRow from '../Timeline/TimelineRow'; import CurrentTimeIndicator from '../Timeline/CurrentTimeIndicator'; import Sidebar from './Sidebar'; import { Event, Resource, PendingAppointment } from '../../types'; import { calculateLayout } from '../../lib/layoutAlgorithm'; import { DEFAULT_PIXELS_PER_HOUR, SNAP_MINUTES } from '../../lib/timelineUtils'; import { useQuery } from '@tanstack/react-query'; import { adaptResources, adaptEvents, adaptPending } from '../../lib/uiAdapter'; import apiClient from '../../api/client'; type ViewMode = 'day' | 'week' | 'month'; export const Timeline: React.FC = () => { // Data Fetching const { data: resources = [] } = useQuery({ queryKey: ['resources'], queryFn: async () => { const response = await apiClient.get('/resources/'); return adaptResources(response.data); } }); const { data: backendAppointments = [] } = useQuery({ // Renamed to backendAppointments to avoid conflict with localEvents queryKey: ['appointments'], queryFn: async () => { const response = await apiClient.get('/appointments/'); return response.data; // Still return raw data, adapt in useEffect } }); // State const [localEvents, setLocalEvents] = useState([]); const [localPending, setLocalPending] = useState([]); // Sync remote data to local state (for optimistic UI updates later) useEffect(() => { if (backendAppointments.length > 0) { setLocalEvents(adaptEvents(backendAppointments)); setLocalPending(adaptPending(backendAppointments)); } }, [backendAppointments]); const [viewMode, setViewMode] = useState('day'); const [currentDate, setCurrentDate] = useState(new Date()); const [pixelsPerHour, setPixelsPerHour] = useState(DEFAULT_PIXELS_PER_HOUR); const [activeDragItem, setActiveDragItem] = useState(null); const timelineScrollRef = useRef(null); const sidebarScrollRef = useRef(null); const hasScrolledRef = useRef(false); // Sensors for drag detection const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5, }, }) ); // Calculate view range const { startTime, endTime, days } = useMemo(() => { let start, end; if (viewMode === 'day') { start = startOfDay(currentDate); end = endOfDay(currentDate); } else if (viewMode === 'week') { start = startOfWeek(currentDate, { weekStartsOn: 1 }); end = endOfWeek(currentDate, { weekStartsOn: 1 }); } else { start = startOfMonth(currentDate); end = endOfMonth(currentDate); } const days = eachDayOfInterval({ start, end }); return { startTime: start, endTime: end, days }; }, [viewMode, currentDate]); // Calculate Layouts for Sidebar Sync const resourceLayouts = useMemo(() => { return resources.map(resource => { const resourceEvents = localEvents.filter(e => e.resourceId === resource.id); const eventsWithLanes = calculateLayout(resourceEvents); const maxLane = Math.max(0, ...eventsWithLanes.map(e => e.laneIndex || 0)); const height = (maxLane + 1) * 40 + 20; // 40 is eventHeight, 20 is padding return { resourceId: resource.id, resourceName: resource.name, height, laneCount: maxLane + 1 }; }); }, [resources, localEvents]); // Scroll Sync Logic const handleTimelineScroll = () => { if (timelineScrollRef.current && sidebarScrollRef.current) { sidebarScrollRef.current.scrollTop = timelineScrollRef.current.scrollTop; } }; // Date Range Label const getDateRangeLabel = () => { if (viewMode === 'day') { return format(currentDate, 'EEEE, MMMM d, yyyy'); } else if (viewMode === 'week') { const start = startOfWeek(currentDate, { weekStartsOn: 1 }); const end = endOfWeek(currentDate, { weekStartsOn: 1 }); return `${format(start, 'MMM d')} - ${format(end, 'MMM d, yyyy')}`; } else { return format(currentDate, 'MMMM yyyy'); } }; // Auto-scroll useEffect(() => { if (timelineScrollRef.current && !hasScrolledRef.current) { const indicator = document.getElementById('current-time-indicator'); if (indicator) { indicator.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); hasScrolledRef.current = true; } } }, [startTime, viewMode]); useEffect(() => { hasScrolledRef.current = false; }, [viewMode]); const handleDragStart = (event: any) => { setActiveDragItem(event.active.data.current); }; // Handle Drag End const handleDragEnd = (event: DragEndEvent) => { const { active, delta, over } = event; setActiveDragItem(null); if (!active) return; let newResourceId: number | undefined; if (over && over.id.toString().startsWith('resource-')) { newResourceId = Number(over.id.toString().replace('resource-', '')); } // Handle Pending Event Drop if (active.data.current?.type === 'pending') { if (newResourceId) { const pendingId = Number(active.id.toString().replace('pending-', '')); const pendingItem = localPending.find(p => p.id === pendingId); if (pendingItem) { const dropRect = active.rect.current.translated; const containerRect = timelineScrollRef.current?.getBoundingClientRect(); if (dropRect && containerRect) { // Calculate relative X position in the timeline content const relativeX = dropRect.left - containerRect.left + (timelineScrollRef.current?.scrollLeft || 0); const pixelsPerMinute = pixelsPerHour / 60; const minutesFromStart = Math.max(0, relativeX / pixelsPerMinute); const snappedMinutes = Math.round(minutesFromStart / SNAP_MINUTES) * SNAP_MINUTES; const newStart = addMinutes(startTime, snappedMinutes); const newEnd = addMinutes(newStart, pendingItem.durationMinutes); const newEvent: Event = { id: Date.now(), resourceId: newResourceId, title: pendingItem.customerName, start: newStart, end: newEnd, status: 'CONFIRMED' }; setLocalEvents(prev => [...prev, newEvent]); setLocalPending(prev => prev.filter(p => p.id !== pendingId)); } } } return; } // Handle Existing Event Drag const eventId = Number(active.id.toString().replace('event-', '')); setLocalEvents(prev => prev.map(e => { if (e.id === eventId) { const minutesShift = Math.round(delta.x / (pixelsPerHour / 60)); const snappedShift = Math.round(minutesShift / SNAP_MINUTES) * SNAP_MINUTES; const updates: Partial = {}; if (snappedShift !== 0) { updates.start = addMinutes(e.start, snappedShift); updates.end = addMinutes(e.end, snappedShift); } if (newResourceId !== undefined && newResourceId !== e.resourceId) { updates.resourceId = newResourceId; } return { ...e, ...updates }; } return e; })); }; const handleResizeStart = (_e: React.MouseEvent, direction: 'left' | 'right', id: number) => { console.log('Resize started', direction, id); }; const handleZoomIn = () => setPixelsPerHour(prev => Math.min(prev + 20, 300)); const handleZoomOut = () => setPixelsPerHour(prev => Math.max(prev - 20, 40)); return (
{/* Header Bar */}
{/* Date Navigation */}
{getDateRangeLabel()}
{/* View Mode Switcher */}
{(['day', 'week', 'month'] as const).map((mode) => ( ))}
{/* Zoom Controls */}
Zoom
{/* Undo/Redo */}
{/* Main Layout */}
{/* Sidebar (Resources + Pending) */} {/* Timeline Grid */}
{/* Current Time Indicator */}
{/* Header Row */}
{viewMode === 'day' ? ( Array.from({ length: 24 }).map((_, i) => (
{format(new Date().setHours(i, 0, 0, 0), 'h a')}
)) ) : viewMode === 'week' ? ( days.map((day, i) => (
{format(day, 'EEEE, MMM d')}
{Array.from({ length: 24 }).map((_, h) => (
{h % 6 === 0 ? format(new Date().setHours(h, 0, 0, 0), 'h a') : ''}
))}
)) ) : ( days.map((day, i) => (
{format(day, 'd')}
)) )}
{/* Resource Rows (Grid Only) */} {resourceLayouts.map(layout => ( e.resourceId === layout.resourceId)} startTime={startTime} endTime={endTime} hourWidth={pixelsPerHour} eventHeight={40} height={layout.height} onResizeStart={handleResizeStart} /> ))}
{/* Drag Overlay for Visual Feedback */} {activeDragItem ? (

{activeDragItem.title}

{activeDragItem.duration} min
) : null}
); }; export default Timeline;