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>
337 lines
16 KiB
TypeScript
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;
|