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

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

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

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