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:
162
frontend/src/components/Schedule/DraggableEvent.tsx
Normal file
162
frontend/src/components/Schedule/DraggableEvent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
frontend/src/components/Schedule/PendingSidebar.tsx
Normal file
77
frontend/src/components/Schedule/PendingSidebar.tsx
Normal 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;
|
||||
133
frontend/src/components/Schedule/Sidebar.tsx
Normal file
133
frontend/src/components/Schedule/Sidebar.tsx
Normal 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;
|
||||
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