/** * 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 = ({ user, business }) => { const { data: appointments = [] } = useAppointments(); const { data: resources = [] } = useResources(); const { data: services = [] } = useServices(); const updateMutation = useUpdateAppointment(); const [blockers, setBlockers] = useState([]); 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(null); const scrollContainerRef = useRef(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) => { 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 (

Schedule: {myResource?.name}

Viewing appointments for {viewDate.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.

{/* Time Gutter */}
{timeMarkersVertical.map((time, i) => (
{time.endsWith(':00') && {time}}
))}
{/* Agenda */}
{ if (business.resourcesCanReschedule) e.preventDefault(); }} onDrop={handleVerticalDrop} >
{timeMarkersVertical.map((_, i) => (
))} {[...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 (
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 }} >
{isAppointment ? (item as Appointment).customerName : item.title} {isCompleted && }
{isAppointment &&
{service?.name}
}
{isAppointment && (item as Appointment).status === 'COMPLETED' ? ( ) : isAppointment ? ( ) : ( )} {startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - {endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
); })}
{isBlockTimeModalOpen && (
setIsBlockTimeModalOpen(false)}>
e.stopPropagation()}>

Add Time Off

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" />
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" />
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" />
)}
); }; export default ResourceScheduler;