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:
38
frontend/src/components/Timeline/CurrentTimeIndicator.tsx
Normal file
38
frontend/src/components/Timeline/CurrentTimeIndicator.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { differenceInMinutes } from 'date-fns';
|
||||
import { getPosition } from '../../lib/timelineUtils';
|
||||
|
||||
interface CurrentTimeIndicatorProps {
|
||||
startTime: Date;
|
||||
hourWidth: number;
|
||||
}
|
||||
|
||||
const CurrentTimeIndicator: React.FC<CurrentTimeIndicatorProps> = ({ startTime, hourWidth }) => {
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(new Date()), 60000); // Update every minute
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Calculate position
|
||||
const left = getPosition(now, startTime, hourWidth);
|
||||
|
||||
// Only render if within visible range (roughly)
|
||||
if (differenceInMinutes(now, startTime) < 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none"
|
||||
style={{ left }}
|
||||
id="current-time-indicator"
|
||||
>
|
||||
<div className="absolute -top-1 -left-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
<div className="absolute top-0 left-2 text-xs font-bold text-red-500 bg-white/80 px-1 rounded">
|
||||
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentTimeIndicator;
|
||||
117
frontend/src/components/Timeline/DraggableEvent.tsx
Normal file
117
frontend/src/components/Timeline/DraggableEvent.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { format } from 'date-fns';
|
||||
import { clsx } from 'clsx';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
interface DraggableEventProps {
|
||||
id: number;
|
||||
title: string;
|
||||
serviceName?: string;
|
||||
status?: 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW' | 'SCHEDULED';
|
||||
isPaid?: boolean;
|
||||
start: Date;
|
||||
end: Date;
|
||||
laneIndex: number;
|
||||
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,
|
||||
status = 'SCHEDULED',
|
||||
isPaid = false,
|
||||
start,
|
||||
end,
|
||||
height,
|
||||
left,
|
||||
width,
|
||||
top,
|
||||
onResizeStart,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `event-${id}`,
|
||||
data: { id, type: 'event', originalStart: start, originalEnd: end },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
left,
|
||||
width,
|
||||
top,
|
||||
height,
|
||||
};
|
||||
|
||||
// Status-based color scheme matching reference UI
|
||||
const getBorderColor = () => {
|
||||
if (isPaid) return 'border-green-500';
|
||||
switch (status) {
|
||||
case 'CONFIRMED': return 'border-blue-500';
|
||||
case 'COMPLETED': return 'border-green-500';
|
||||
case 'CANCELLED': return 'border-red-500';
|
||||
case 'NO_SHOW': return 'border-gray-500';
|
||||
default: return 'border-brand-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={clsx(
|
||||
"absolute rounded-b overflow-hidden group transition-shadow",
|
||||
"bg-brand-100 dark:bg-brand-900/50 border-t-4",
|
||||
getBorderColor(),
|
||||
isDragging ? "shadow-lg ring-2 ring-brand-500 opacity-80 z-50" : "hover:shadow-md z-10"
|
||||
)}
|
||||
>
|
||||
{/* Top Resize Handle */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onResizeStart(e, 'left', id);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="h-full w-full px-2 py-1 cursor-move select-none"
|
||||
>
|
||||
<div className="flex items-start justify-between mt-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<GripVertical size={14} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
{serviceName && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{serviceName}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{format(start, 'h:mm a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Resize Handle */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onResizeStart(e, 'right', id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
frontend/src/components/Timeline/ResourceRow.tsx
Normal file
99
frontend/src/components/Timeline/ResourceRow.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { clsx } from 'clsx';
|
||||
import { differenceInHours } from 'date-fns';
|
||||
import { calculateLayout, Event } from '../../lib/layoutAlgorithm';
|
||||
import { DraggableEvent } from './DraggableEvent';
|
||||
import { getPosition } from '../../lib/timelineUtils';
|
||||
|
||||
interface ResourceRowProps {
|
||||
resourceId: number;
|
||||
resourceName: string;
|
||||
events: Event[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
hourWidth: number;
|
||||
eventHeight: number;
|
||||
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
|
||||
}
|
||||
|
||||
const ResourceRow: React.FC<ResourceRowProps> = ({
|
||||
resourceId,
|
||||
resourceName,
|
||||
events,
|
||||
startTime,
|
||||
endTime,
|
||||
hourWidth,
|
||||
eventHeight,
|
||||
onResizeStart,
|
||||
}) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `resource-${resourceId}`,
|
||||
data: { resourceId },
|
||||
});
|
||||
|
||||
const eventsWithLanes = useMemo(() => calculateLayout(events), [events]);
|
||||
const maxLane = Math.max(0, ...eventsWithLanes.map(e => e.laneIndex || 0));
|
||||
const rowHeight = (maxLane + 1) * eventHeight + 20;
|
||||
|
||||
const totalWidth = getPosition(endTime, startTime, hourWidth);
|
||||
|
||||
// Calculate total hours for grid lines
|
||||
const totalHours = Math.ceil(differenceInHours(endTime, startTime));
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-gray-200 group">
|
||||
<div
|
||||
className="w-48 flex-shrink-0 p-4 border-r border-gray-200 bg-gray-50 font-medium flex items-center sticky left-0 z-10 group-hover:bg-gray-100 transition-colors"
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
{resourceName}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={clsx(
|
||||
"relative flex-grow transition-colors",
|
||||
isOver ? "bg-blue-50" : ""
|
||||
)}
|
||||
style={{ height: rowHeight, width: totalWidth }}
|
||||
>
|
||||
{/* Grid Lines */}
|
||||
<div className="absolute inset-0 pointer-events-none flex">
|
||||
{Array.from({ length: totalHours }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-r border-gray-100 h-full"
|
||||
style={{ width: hourWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
{eventsWithLanes.map((event) => {
|
||||
const left = getPosition(event.start, startTime, hourWidth);
|
||||
const width = getPosition(event.end, startTime, hourWidth) - left;
|
||||
const top = (event.laneIndex || 0) * eventHeight + 10;
|
||||
|
||||
return (
|
||||
<DraggableEvent
|
||||
key={event.id}
|
||||
id={event.id}
|
||||
title={event.title}
|
||||
start={event.start}
|
||||
end={event.end}
|
||||
laneIndex={event.laneIndex || 0}
|
||||
height={eventHeight - 4}
|
||||
left={left}
|
||||
width={width}
|
||||
top={top}
|
||||
onResizeStart={onResizeStart}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceRow;
|
||||
88
frontend/src/components/Timeline/TimelineRow.tsx
Normal file
88
frontend/src/components/Timeline/TimelineRow.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { clsx } from 'clsx';
|
||||
import { differenceInHours } from 'date-fns';
|
||||
import { calculateLayout, Event } from '../../lib/layoutAlgorithm';
|
||||
import { DraggableEvent } from './DraggableEvent';
|
||||
import { getPosition } from '../../lib/timelineUtils';
|
||||
|
||||
interface TimelineRowProps {
|
||||
resourceId: number;
|
||||
events: Event[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
hourWidth: number;
|
||||
eventHeight: number;
|
||||
height: number; // Passed from parent to match sidebar
|
||||
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
|
||||
}
|
||||
|
||||
const TimelineRow: React.FC<TimelineRowProps> = ({
|
||||
resourceId,
|
||||
events,
|
||||
startTime,
|
||||
endTime,
|
||||
hourWidth,
|
||||
eventHeight,
|
||||
height,
|
||||
onResizeStart,
|
||||
}) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `resource-${resourceId}`,
|
||||
data: { resourceId },
|
||||
});
|
||||
|
||||
const eventsWithLanes = useMemo(() => calculateLayout(events), [events]);
|
||||
const totalWidth = getPosition(endTime, startTime, hourWidth);
|
||||
const totalHours = Math.ceil(differenceInHours(endTime, startTime));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={clsx(
|
||||
"relative border-b border-gray-200 dark:border-gray-700 transition-colors group",
|
||||
isOver ? "bg-blue-50 dark:bg-blue-900/20" : ""
|
||||
)}
|
||||
style={{ height, width: totalWidth }}
|
||||
>
|
||||
{/* Grid Lines */}
|
||||
<div className="absolute inset-0 pointer-events-none flex">
|
||||
{Array.from({ length: totalHours }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-r border-gray-100 dark:border-gray-700/50 h-full"
|
||||
style={{ width: hourWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
{eventsWithLanes.map((event) => {
|
||||
const left = getPosition(event.start, startTime, hourWidth);
|
||||
const width = getPosition(event.end, startTime, hourWidth) - left;
|
||||
const top = (event.laneIndex || 0) * eventHeight + 10;
|
||||
|
||||
return (
|
||||
<DraggableEvent
|
||||
key={event.id}
|
||||
id={event.id}
|
||||
title={event.title}
|
||||
serviceName={event.serviceName}
|
||||
status={event.status}
|
||||
isPaid={event.isPaid}
|
||||
start={event.start}
|
||||
end={event.end}
|
||||
laneIndex={event.laneIndex || 0}
|
||||
height={eventHeight - 4}
|
||||
left={left}
|
||||
width={width}
|
||||
top={top}
|
||||
onResizeStart={onResizeStart}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineRow;
|
||||
Reference in New Issue
Block a user