Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
443
frontend/src/components/Schedule/Timeline.tsx
Normal file
443
frontend/src/components/Schedule/Timeline.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
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 axios from 'axios';
|
||||
|
||||
type ViewMode = 'day' | 'week' | 'month';
|
||||
|
||||
export const Timeline: React.FC = () => {
|
||||
// Data Fetching
|
||||
const { data: resources = [] } = useQuery({
|
||||
queryKey: ['resources'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get('http://lvh.me:8000/api/resources/');
|
||||
return adaptResources(response.data);
|
||||
}
|
||||
});
|
||||
|
||||
const { data: backendAppointments = [] } = useQuery({ // Renamed to backendAppointments to avoid conflict with localEvents
|
||||
queryKey: ['appointments'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get('http://lvh.me:8000/api/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;
|
||||
Reference in New Issue
Block a user