Files
smoothschedule/frontend/src/components/Schedule/Timeline.tsx
poduck 980b5d36aa fix(scheduler): Update Timeline to use apiClient for authenticated resource fetching
The Timeline component was using a raw axios instance with hardcoded URLs, causing it to bypass authentication and tenant context headers. This resulted in empty or failed data fetches. Updated it to use the configured 'apiClient', ensuring that the authentication token and 'X-Business-Subdomain' headers are correctly sent, allowing the backend to return the appropriate tenant-specific resources and appointments.
2025-12-01 02:32:48 -05:00

444 lines
22 KiB
TypeScript

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<Event[]>([]);
const [localPending, setLocalPending] = useState<PendingAppointment[]>([]);
// 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<ViewMode>('day');
const [currentDate, setCurrentDate] = useState(new Date());
const [pixelsPerHour, setPixelsPerHour] = useState(DEFAULT_PIXELS_PER_HOUR);
const [activeDragItem, setActiveDragItem] = useState<any>(null);
const timelineScrollRef = useRef<HTMLDivElement>(null);
const sidebarScrollRef = useRef<HTMLDivElement>(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<ResourceLayout[]>(() => {
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<Event> = {};
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 (
<div className="flex flex-col h-full overflow-hidden select-none bg-white dark:bg-gray-900 transition-colors duration-200">
{/* Header Bar */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm shrink-0 z-10 transition-colors duration-200">
<div className="flex items-center gap-4">
{/* Date Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentDate(d => addMinutes(d, viewMode === 'day' ? -1440 : -10080))}
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Previous"
>
<ChevronLeft size={20} />
</button>
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-md text-gray-700 dark:text-gray-200 font-medium transition-colors duration-200 w-[320px] justify-center">
<CalendarIcon size={16} />
<span className="text-center">{getDateRangeLabel()}</span>
</div>
<button
onClick={() => setCurrentDate(d => addMinutes(d, viewMode === 'day' ? 1440 : 10080))}
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Next"
>
<ChevronRight size={20} />
</button>
</div>
{/* View Mode Switcher */}
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
{(['day', 'week', 'month'] as const).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={clsx(
"px-3 py-1.5 text-sm font-medium rounded transition-colors capitalize",
viewMode === mode
? "bg-blue-500 text-white"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
)}
>
{mode}
</button>
))}
</div>
{/* Zoom Controls */}
<div className="flex items-center gap-2">
<button
className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
onClick={handleZoomOut}
>
<ZoomOut size={16} />
</button>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Zoom</span>
<button
className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
onClick={handleZoomIn}
>
<ZoomIn size={16} />
</button>
</div>
{/* Undo/Redo */}
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
<button className="p-2 text-gray-300 dark:text-gray-600 cursor-not-allowed rounded" disabled>
<Undo size={18} />
</button>
<button className="p-2 text-gray-300 dark:text-gray-600 cursor-not-allowed rounded" disabled>
<Redo size={18} />
</button>
</div>
</div>
<div className="flex items-center gap-3">
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
+ New Appointment
</button>
<button className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 transition-colors">
<Filter size={18} />
</button>
</div>
</div>
{/* Main Layout */}
<div className="flex flex-1 overflow-hidden">
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* Sidebar (Resources + Pending) */}
<Sidebar
resourceLayouts={resourceLayouts}
pendingAppointments={localPending}
scrollRef={sidebarScrollRef}
/>
{/* Timeline Grid */}
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-gray-900 relative transition-colors duration-200">
<div
ref={timelineScrollRef}
onScroll={handleTimelineScroll}
className="flex-1 overflow-auto timeline-scroll"
>
<div className="min-w-max relative min-h-full">
{/* Current Time Indicator */}
<div className="absolute inset-y-0 left-0 right-0 pointer-events-none z-40">
<CurrentTimeIndicator startTime={startTime} hourWidth={pixelsPerHour} />
</div>
{/* Header Row */}
<div className="sticky top-0 z-10 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
<div className="flex" style={{ height: 48 }}>
{viewMode === 'day' ? (
Array.from({ length: 24 }).map((_, i) => (
<div
key={i}
className="flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-2 text-sm text-gray-500 font-medium box-border"
style={{ width: pixelsPerHour }}
>
{format(new Date().setHours(i, 0, 0, 0), 'h a')}
</div>
))
) : viewMode === 'week' ? (
days.map((day, i) => (
<div
key={i}
className="flex-shrink-0 border-r border-gray-300 dark:border-gray-600"
style={{ width: pixelsPerHour * 24 }}
>
<div className={clsx(
"p-2 text-sm font-bold text-center border-b border-gray-100 dark:border-gray-700",
isSameDay(day, new Date()) ? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400" : "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
)}>
{format(day, 'EEEE, MMM d')}
</div>
<div className="flex">
{Array.from({ length: 24 }).map((_, h) => (
<div
key={h}
className="flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-1 text-xs text-gray-400 text-center"
style={{ width: pixelsPerHour }}
>
{h % 6 === 0 ? format(new Date().setHours(h, 0, 0, 0), 'h a') : ''}
</div>
))}
</div>
</div>
))
) : (
days.map((day, i) => (
<div
key={i}
className={clsx(
"flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-2 text-sm font-medium text-center",
isSameDay(day, new Date()) ? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400" : "text-gray-500"
)}
style={{ width: 100 }}
>
{format(day, 'd')}
</div>
))
)}
</div>
</div>
{/* Resource Rows (Grid Only) */}
{resourceLayouts.map(layout => (
<TimelineRow
key={layout.resourceId}
resourceId={layout.resourceId}
events={localEvents.filter(e => e.resourceId === layout.resourceId)}
startTime={startTime}
endTime={endTime}
hourWidth={pixelsPerHour}
eventHeight={40}
height={layout.height}
onResizeStart={handleResizeStart}
/>
))}
</div>
</div>
</div>
{/* Drag Overlay for Visual Feedback */}
<DragOverlay>
{activeDragItem ? (
<div className="p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-lg opacity-80 w-64">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{activeDragItem.title}</p>
</div>
<GripVertical size={14} className="text-gray-400" />
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} />
<span>{activeDragItem.duration} min</span>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
);
};
export default Timeline;