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,162 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { clsx } from 'clsx';
import { Clock, DollarSign } from 'lucide-react';
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
export interface DraggableEventProps {
id: number;
title: string;
serviceName?: string;
start: Date;
end: Date;
status?: AppointmentStatus;
isPaid?: boolean;
height: number;
left: number;
width: number;
top: number;
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
}
export const DraggableEvent: React.FC<DraggableEventProps> = ({
id,
title,
serviceName,
start,
end,
status = 'CONFIRMED',
isPaid = false,
height,
left,
width,
top,
onResizeStart,
}) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `event-${id}`,
data: {
type: 'event',
title,
duration: (end.getTime() - start.getTime()) / 60000
},
});
const style: React.CSSProperties = {
transform: CSS.Translate.toString(transform),
left,
width,
top,
height,
position: 'absolute',
zIndex: isDragging ? 50 : 10,
};
// Status Logic matching legacy OwnerScheduler.tsx exactly
const getStatusStyles = () => {
const now = new Date();
// Legacy: if (status === 'COMPLETED' || status === 'NO_SHOW')
if (status === 'COMPLETED' || status === 'NO_SHOW') {
return {
container: 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400',
accent: 'bg-gray-400'
};
}
// Legacy: if (status === 'CANCELLED')
if (status === 'CANCELLED') {
return {
container: 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400',
accent: 'bg-gray-400'
};
}
// Legacy: if (now > endTime) (Overdue)
if (now > end) {
return {
container: 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200',
accent: 'bg-red-500'
};
}
// Legacy: if (now >= startTime && now <= endTime) (In Progress)
if (now >= start && now <= end) {
return {
container: 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200',
accent: 'bg-yellow-500 animate-pulse'
};
}
// Legacy: Default (Future)
return {
container: 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200',
accent: 'bg-blue-500'
};
};
const styles = getStatusStyles();
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={clsx(
"rounded-md border shadow-sm text-xs overflow-hidden cursor-pointer group transition-all select-none flex",
styles.container,
isDragging ? "opacity-50 ring-2 ring-blue-500 ring-offset-2 z-50 shadow-xl" : "hover:shadow-md"
)}
>
{/* Colored Status Strip */}
<div className={clsx("w-1.5 shrink-0", styles.accent)} />
{/* Content */}
<div className="flex-1 p-1.5 min-w-0 flex flex-col justify-center">
<div className="flex items-center justify-between gap-1">
<span className="font-semibold truncate">
{title}
</span>
{isPaid && (
<DollarSign size={10} className="text-emerald-600 dark:text-emerald-400 shrink-0" />
)}
</div>
{serviceName && width > 100 && (
<div className="text-[10px] opacity-80 truncate">
{serviceName}
</div>
)}
{/* Time (only show if wide enough) */}
{width > 60 && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] opacity-70">
<Clock size={8} />
<span className="truncate">
{start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
</div>
)}
</div>
{/* Resize Handles */}
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-400/50 z-20 opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
onResizeStart(e, 'left', id);
}}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-400/50 z-20 opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
onResizeStart(e, 'right', id);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Clock, GripVertical } from 'lucide-react';
import { clsx } from 'clsx';
export interface PendingAppointment {
id: number;
customerName: string;
serviceName: string;
durationMinutes: number;
}
interface PendingItemProps {
appointment: PendingAppointment;
}
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `pending-${appointment.id}`,
data: {
type: 'pending',
duration: appointment.durationMinutes,
title: appointment.customerName // Pass title for the new event
},
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={clsx(
"p-3 bg-white border border-l-4 border-gray-200 border-l-orange-400 rounded shadow-sm cursor-grab hover:shadow-md transition-all mb-2",
isDragging ? "opacity-50" : ""
)}
>
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-sm text-gray-900">{appointment.customerName}</p>
<p className="text-xs text-gray-500">{appointment.serviceName}</p>
</div>
<GripVertical size={14} className="text-gray-400" />
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400">
<Clock size={10} />
<span>{appointment.durationMinutes} min</span>
</div>
</div>
);
};
interface PendingSidebarProps {
appointments: PendingAppointment[];
}
const PendingSidebar: React.FC<PendingSidebarProps> = ({ appointments }) => {
return (
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full shrink-0">
<div className="p-4 border-b border-gray-200 bg-gray-100">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-2">
<Clock size={12} /> Pending Requests ({appointments.length})
</h3>
</div>
<div className="p-4 overflow-y-auto flex-1">
{appointments.length === 0 ? (
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
) : (
appointments.map(apt => (
<PendingItem key={apt.id} appointment={apt} />
))
)}
</div>
</div>
);
};
export default PendingSidebar;

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Clock, GripVertical, Trash2 } from 'lucide-react';
import { clsx } from 'clsx';
export interface PendingAppointment {
id: number;
customerName: string;
serviceName: string;
durationMinutes: number;
}
export interface ResourceLayout {
resourceId: number;
resourceName: string;
height: number;
laneCount: number;
}
interface PendingItemProps {
appointment: PendingAppointment;
}
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `pending-${appointment.id}`,
data: {
type: 'pending',
duration: appointment.durationMinutes,
title: appointment.customerName
},
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={clsx(
"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-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-all mb-2",
isDragging ? "opacity-50" : ""
)}
>
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{appointment.customerName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{appointment.serviceName}</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>{appointment.durationMinutes} min</span>
</div>
</div>
);
};
interface SidebarProps {
resourceLayouts: ResourceLayout[];
pendingAppointments: PendingAppointment[];
scrollRef: React.RefObject<HTMLDivElement>;
}
const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments, scrollRef }) => {
return (
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: 250 }}>
{/* Resources Header */}
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: 48 }}>
Resources
</div>
{/* Resources List (Synced Scroll) */}
<div className="flex-1 overflow-hidden flex flex-col">
<div
ref={scrollRef}
className="overflow-hidden flex-1" // Hidden scrollbar, controlled by main timeline
>
{resourceLayouts.map(layout => (
<div
key={layout.resourceId}
className="flex items-center px-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group"
style={{ height: layout.height }}
>
<div className="flex items-center gap-3 w-full">
<div className="flex items-center justify-center w-8 h-8 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0">
<GripVertical size={16} />
</div>
<div>
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resourceName}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">
Resource
{layout.laneCount > 1 && (
<span className="text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/50 px-1 rounded text-[10px]">
{layout.laneCount} lanes
</span>
)}
</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* Pending Requests (Fixed Bottom) */}
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200">
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0">
<Clock size={12} /> Pending Requests ({pendingAppointments.length})
</h3>
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
{pendingAppointments.length === 0 ? (
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
) : (
pendingAppointments.map(apt => (
<PendingItem key={apt.id} appointment={apt} />
))
)}
</div>
{/* Archive Drop Zone (Visual) */}
<div className="shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 opacity-50">
<div className="flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700 bg-transparent text-gray-400">
<Trash2 size={16} />
<span className="text-xs font-medium">Drop here to archive</span>
</div>
</div>
</div>
</div>
);
};
export default Sidebar;

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;