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:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

View 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;