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:
336
frontend/src/pages/ResourceScheduler.tsx
Normal file
336
frontend/src/pages/ResourceScheduler.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Resource Scheduler - Vertical agenda view for resource users
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { Appointment, User, Business, Blocker } from '../types';
|
||||
import { Clock, CheckCircle2, Lock, Plus, X, ChevronLeft, ChevronRight, Ban } from 'lucide-react';
|
||||
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import Portal from '../components/Portal';
|
||||
|
||||
// Time settings
|
||||
const START_HOUR = 8;
|
||||
const END_HOUR = 18;
|
||||
const PIXELS_PER_MINUTE_VERTICAL = 2;
|
||||
|
||||
interface ResourceSchedulerProps {
|
||||
user: User;
|
||||
business: Business;
|
||||
}
|
||||
|
||||
const ResourceScheduler: React.FC<ResourceSchedulerProps> = ({ user, business }) => {
|
||||
const { data: appointments = [] } = useAppointments();
|
||||
const { data: resources = [] } = useResources();
|
||||
const { data: services = [] } = useServices();
|
||||
const updateMutation = useUpdateAppointment();
|
||||
|
||||
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||
const [viewDate, setViewDate] = useState(new Date());
|
||||
const [isBlockTimeModalOpen, setIsBlockTimeModalOpen] = useState(false);
|
||||
const [newBlocker, setNewBlocker] = useState({ title: 'Break', startTime: '12:00', durationMinutes: 60 });
|
||||
|
||||
const agendaContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll to current time on mount (centered in view)
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const viewDay = new Date(viewDate);
|
||||
viewDay.setHours(0, 0, 0, 0);
|
||||
|
||||
// Only scroll if viewing today
|
||||
if (viewDay.getTime() !== today.getTime()) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
const containerHeight = container.clientHeight;
|
||||
|
||||
// Calculate current time offset in pixels (vertical)
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(START_HOUR, 0, 0, 0);
|
||||
const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60);
|
||||
const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE_VERTICAL;
|
||||
|
||||
// Scroll so current time is centered
|
||||
const scrollPosition = currentTimeOffset - (containerHeight / 2);
|
||||
container.scrollTop = Math.max(0, scrollPosition);
|
||||
}, []);
|
||||
|
||||
const isSameDay = (d1: Date, d2: Date) =>
|
||||
d1.getFullYear() === d2.getFullYear() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getDate() === d2.getDate();
|
||||
|
||||
const myResource = useMemo(() => resources.find(r => r.userId === user.id), [user.id, resources]);
|
||||
|
||||
const myAppointments = useMemo(
|
||||
() => appointments
|
||||
.filter(a => a.resourceId === myResource?.id && isSameDay(new Date(a.startTime), viewDate))
|
||||
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()),
|
||||
[appointments, myResource, viewDate]
|
||||
);
|
||||
|
||||
const myBlockers = useMemo(
|
||||
() => blockers
|
||||
.filter(b => b.resourceId === myResource?.id && isSameDay(new Date(b.startTime), viewDate))
|
||||
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()),
|
||||
[blockers, myResource, viewDate]
|
||||
);
|
||||
|
||||
const timeMarkersVertical = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i)
|
||||
.flatMap(h => [`${h}:00`, `${h}:30`]);
|
||||
|
||||
const handleVerticalDragStart = (e: React.DragEvent, appointment: Appointment) => {
|
||||
if (!business.resourcesCanReschedule || appointment.status === 'COMPLETED') {
|
||||
return e.preventDefault();
|
||||
}
|
||||
e.dataTransfer.setData('appointmentId', appointment.id);
|
||||
};
|
||||
|
||||
const handleVerticalDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (!business.resourcesCanReschedule || !agendaContainerRef.current) return;
|
||||
|
||||
const appointmentId = e.dataTransfer.getData('appointmentId');
|
||||
const appointment = myAppointments.find(a => a.id === appointmentId);
|
||||
if (!appointment || appointment.status === 'COMPLETED') return;
|
||||
|
||||
const rect = agendaContainerRef.current.getBoundingClientRect();
|
||||
const dropY = e.clientY - rect.top;
|
||||
const minutesFromStart = dropY / PIXELS_PER_MINUTE_VERTICAL;
|
||||
const snappedMinutes = Math.round(minutesFromStart / 15) * 15;
|
||||
const newStartTime = new Date(viewDate);
|
||||
newStartTime.setHours(START_HOUR, snappedMinutes, 0, 0);
|
||||
|
||||
updateMutation.mutate({
|
||||
id: appointmentId,
|
||||
updates: { startTime: newStartTime }
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddBlocker = () => {
|
||||
const [hours, minutes] = newBlocker.startTime.split(':').map(Number);
|
||||
const startTime = new Date(viewDate);
|
||||
startTime.setHours(hours, minutes, 0, 0);
|
||||
|
||||
const newBlock: Blocker = {
|
||||
id: `block_${Date.now()}`,
|
||||
resourceId: myResource!.id,
|
||||
title: newBlocker.title,
|
||||
startTime,
|
||||
durationMinutes: newBlocker.durationMinutes
|
||||
};
|
||||
setBlockers(prev => [...prev, newBlock]);
|
||||
setIsBlockTimeModalOpen(false);
|
||||
};
|
||||
|
||||
const getVerticalOffset = (date: Date) => {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(START_HOUR, 0, 0, 0);
|
||||
const diffMinutes = (date.getTime() - startOfDay.getTime()) / (1000 * 60);
|
||||
return diffMinutes * PIXELS_PER_MINUTE_VERTICAL;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
|
||||
if (status === 'COMPLETED' || status === 'NO_SHOW')
|
||||
return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
|
||||
if (status === 'CANCELLED')
|
||||
return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
|
||||
const now = new Date();
|
||||
if (now > endTime)
|
||||
return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200';
|
||||
if (now >= startTime && now <= endTime)
|
||||
return 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200';
|
||||
return 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Schedule: {myResource?.name}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Viewing appointments for {viewDate.toLocaleDateString(undefined, {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||
})}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsBlockTimeModalOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm"
|
||||
>
|
||||
<Plus size={16} /> Block Time
|
||||
</button>
|
||||
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
|
||||
<button
|
||||
onClick={() => setViewDate(d => new Date(d.setDate(d.getDate() - 1)))}
|
||||
className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-l-md"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewDate(new Date())}
|
||||
className="px-3 py-1.5 text-sm font-semibold text-gray-700 dark:text-gray-200 border-x border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewDate(d => new Date(d.setDate(d.getDate() + 1)))}
|
||||
className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-r-md"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className="h-[70vh] overflow-y-auto timeline-scroll bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm flex">
|
||||
{/* Time Gutter */}
|
||||
<div className="w-20 text-right pr-4 border-r border-gray-100 dark:border-gray-700 shrink-0">
|
||||
{timeMarkersVertical.map((time, i) => (
|
||||
<div key={i} className="text-xs text-gray-400 relative" style={{ height: 30 * PIXELS_PER_MINUTE_VERTICAL }}>
|
||||
{time.endsWith(':00') && <span className="absolute -top-1.5">{time}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Agenda */}
|
||||
<div
|
||||
ref={agendaContainerRef}
|
||||
className="flex-1 relative"
|
||||
onDragOver={(e) => { if (business.resourcesCanReschedule) e.preventDefault(); }}
|
||||
onDrop={handleVerticalDrop}
|
||||
>
|
||||
<div style={{ height: (END_HOUR - START_HOUR) * 60 * PIXELS_PER_MINUTE_VERTICAL }} className="relative">
|
||||
{timeMarkersVertical.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute w-full ${i % 2 === 0 ? 'border-t border-gray-100 dark:border-gray-700' : 'border-t border-dashed border-gray-100 dark:border-gray-800'}`}
|
||||
style={{ top: i * 30 * PIXELS_PER_MINUTE_VERTICAL }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{[...myAppointments, ...myBlockers].map(item => {
|
||||
const isAppointment = 'customerName' in item;
|
||||
const startTime = new Date(item.startTime);
|
||||
const endTime = new Date(startTime.getTime() + item.durationMinutes * 60000);
|
||||
const isCompleted = isAppointment && item.status === 'COMPLETED';
|
||||
const canDrag = business.resourcesCanReschedule && !isCompleted && isAppointment;
|
||||
|
||||
const colorClass = isAppointment
|
||||
? getStatusColor(item.status, startTime, endTime)
|
||||
: 'bg-gray-100 border-gray-300 text-gray-500 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-400';
|
||||
const cursorClass = canDrag ? 'cursor-grab active:cursor-grabbing' : 'cursor-default';
|
||||
const service = isAppointment ? services.find(s => s.id === (item as Appointment).serviceId) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable={canDrag}
|
||||
onDragStart={(e) => isAppointment && handleVerticalDragStart(e, item as Appointment)}
|
||||
className={`absolute left-2 right-2 rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${cursorClass}`}
|
||||
style={{
|
||||
top: getVerticalOffset(startTime),
|
||||
height: item.durationMinutes * PIXELS_PER_MINUTE_VERTICAL,
|
||||
zIndex: 10,
|
||||
backgroundImage: !isAppointment ? `linear-gradient(45deg, rgba(0,0,0,0.05) 25%, transparent 25%, transparent 50%, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.05) 75%, transparent 75%, transparent)` : undefined,
|
||||
backgroundSize: !isAppointment ? '20px 20px' : undefined
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold text-sm truncate flex items-center justify-between">
|
||||
<span>{isAppointment ? (item as Appointment).customerName : item.title}</span>
|
||||
{isCompleted && <Lock size={12} className="text-gray-400 shrink-0" />}
|
||||
</div>
|
||||
{isAppointment && <div className="text-xs truncate opacity-80">{service?.name}</div>}
|
||||
<div className="mt-2 flex items-center gap-1 text-xs opacity-75">
|
||||
{isAppointment && (item as Appointment).status === 'COMPLETED' ? (
|
||||
<CheckCircle2 size={12} />
|
||||
) : isAppointment ? (
|
||||
<Clock size={12} />
|
||||
) : (
|
||||
<Ban size={12} />
|
||||
)}
|
||||
<span>
|
||||
{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
|
||||
{endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBlockTimeModalOpen && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setIsBlockTimeModalOpen(false)}>
|
||||
<div className="w-full max-w-sm bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Add Time Off</h3>
|
||||
<button onClick={() => setIsBlockTimeModalOpen(false)} className="p-1 text-gray-400 hover:bg-gray-100 rounded-full">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBlocker.title}
|
||||
onChange={e => setNewBlocker(s => ({ ...s, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newBlocker.startTime}
|
||||
onChange={e => setNewBlocker(s => ({ ...s, startTime: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duration (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="15"
|
||||
min="15"
|
||||
value={newBlocker.durationMinutes}
|
||||
onChange={e => setNewBlocker(s => ({ ...s, durationMinutes: parseInt(e.target.value, 10) }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setIsBlockTimeModalOpen(false)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddBlocker}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
Add Block
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceScheduler;
|
||||
Reference in New Issue
Block a user