Files
smoothschedule/frontend/src/pages/ResourceScheduler.tsx
poduck 2e111364a2 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>
2025-11-27 01:43:20 -05:00

337 lines
16 KiB
TypeScript

/**
* 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;