/** * Owner Scheduler - Horizontal timeline view for owner/manager/staff users */ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Appointment, User, Business } from '../types'; import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight } from 'lucide-react'; import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments'; import { useResources } from '../hooks/useResources'; import { useServices } from '../hooks/useServices'; import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket'; import Portal from '../components/Portal'; // Time settings const START_HOUR = 0; // Midnight const END_HOUR = 24; // Midnight next day const PIXELS_PER_MINUTE = 2.5; const HEADER_HEIGHT = 48; const SIDEBAR_WIDTH = 250; // Format duration as hours and minutes when >= 60 min const formatDuration = (minutes: number): string => { if (minutes >= 60) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } return `${minutes} min`; }; // Layout settings const MIN_ROW_HEIGHT = 104; const EVENT_HEIGHT = 88; const EVENT_GAP = 8; interface OwnerSchedulerProps { user: User; business: Business; } const OwnerScheduler: React.FC = ({ user, business }) => { type ViewMode = 'day' | 'week' | 'month'; const [viewMode, setViewMode] = useState('day'); const [viewDate, setViewDate] = useState(new Date()); // Calculate date range for fetching appointments based on current view const dateRange = useMemo(() => { const getStartOfWeek = (date: Date): Date => { const d = new Date(date); const day = d.getDay(); d.setDate(d.getDate() - day); d.setHours(0, 0, 0, 0); return d; }; if (viewMode === 'day') { const start = new Date(viewDate); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); return { startDate: start, endDate: end }; } else if (viewMode === 'week') { const start = getStartOfWeek(viewDate); const end = new Date(start); end.setDate(end.getDate() + 7); return { startDate: start, endDate: end }; } else { // Month view const start = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1); const end = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1); return { startDate: start, endDate: end }; } }, [viewMode, viewDate]); // Fetch only appointments in the visible date range (plus pending ones) const { data: appointments = [] } = useAppointments(dateRange); const { data: resources = [] } = useResources(); const { data: services = [] } = useServices(); const updateMutation = useUpdateAppointment(); const deleteMutation = useDeleteAppointment(); // Connect to WebSocket for real-time updates useAppointmentWebSocket(); const [zoomLevel, setZoomLevel] = useState(1); const [draggedAppointmentId, setDraggedAppointmentId] = useState(null); const [dragOffsetMinutes, setDragOffsetMinutes] = useState(0); // Track where on appointment drag started const [previewState, setPreviewState] = useState<{ resourceId: string; startTime: Date; } | null>(null); const [resizeState, setResizeState] = useState<{ appointmentId: string; direction: 'start' | 'end'; startX: number; originalStart: Date; originalDuration: number; newStart?: Date; newDuration?: number; } | null>(null); const [selectedAppointment, setSelectedAppointment] = useState(null); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); // State for editing appointments const [editDateTime, setEditDateTime] = useState(''); const [editResource, setEditResource] = useState(''); const [editDuration, setEditDuration] = useState(0); // Update edit state when selected appointment changes useEffect(() => { if (selectedAppointment) { setEditDateTime(new Date(selectedAppointment.startTime).toISOString().slice(0, 16)); setEditResource(selectedAppointment.resourceId || ''); setEditDuration(selectedAppointment.durationMinutes); } }, [selectedAppointment]); // Undo/Redo history type HistoryAction = { type: 'move' | 'resize'; appointmentId: string; before: { startTime: Date; resourceId: string | null; durationMinutes?: number }; after: { startTime: Date; resourceId: string | null; durationMinutes?: number }; }; const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const scrollContainerRef = useRef(null); // Keyboard shortcuts for undo/redo useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [historyIndex, history]); // Scroll to current time on mount (centered in view) useEffect(() => { if (!scrollContainerRef.current) return; const now = new Date(); const today = new Date(viewDate); today.setHours(0, 0, 0, 0); const nowDay = new Date(now); nowDay.setHours(0, 0, 0, 0); // Only scroll if today is in the current view if (viewMode === 'day' && nowDay.getTime() !== today.getTime()) return; const container = scrollContainerRef.current; const containerWidth = container.clientWidth; // Calculate current time offset in pixels 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 * zoomLevel; // Scroll so current time is centered const scrollPosition = currentTimeOffset - (containerWidth / 2); container.scrollLeft = Math.max(0, scrollPosition); }, []); const addToHistory = (action: HistoryAction) => { // Remove any history after current index (when doing new action after undo) const newHistory = history.slice(0, historyIndex + 1); newHistory.push(action); // Limit history to 50 actions if (newHistory.length > 50) { newHistory.shift(); } else { setHistoryIndex(historyIndex + 1); } setHistory(newHistory); }; const undo = () => { if (historyIndex < 0) return; const action = history[historyIndex]; const appointment = appointments.find(a => a.id === action.appointmentId); if (!appointment) return; // Revert to "before" state updateMutation.mutate({ id: action.appointmentId, updates: { startTime: action.before.startTime, resourceId: action.before.resourceId, ...(action.before.durationMinutes !== undefined && { durationMinutes: action.before.durationMinutes }) } }); setHistoryIndex(historyIndex - 1); }; const redo = () => { if (historyIndex >= history.length - 1) return; const action = history[historyIndex + 1]; const appointment = appointments.find(a => a.id === action.appointmentId); if (!appointment) return; // Apply "after" state updateMutation.mutate({ id: action.appointmentId, updates: { startTime: action.after.startTime, resourceId: action.after.resourceId, ...(action.after.durationMinutes !== undefined && { durationMinutes: action.after.durationMinutes }) } }); setHistoryIndex(historyIndex + 1); }; // Date navigation helpers const getStartOfWeek = (date: Date): Date => { const d = new Date(date); const day = d.getDay(); const diff = d.getDate() - day; // Sunday as start of week return new Date(d.setDate(diff)); }; const getStartOfMonth = (date: Date): Date => { return new Date(date.getFullYear(), date.getMonth(), 1); }; const getEndOfMonth = (date: Date): Date => { return new Date(date.getFullYear(), date.getMonth() + 1, 0); }; const navigateDate = (direction: 'prev' | 'next') => { const newDate = new Date(viewDate); if (viewMode === 'day') { newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1)); } else if (viewMode === 'week') { newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7)); } else if (viewMode === 'month') { newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1)); } setViewDate(newDate); }; const getDateRangeLabel = (): string => { if (viewMode === 'day') { return viewDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); } else if (viewMode === 'week') { const weekStart = getStartOfWeek(viewDate); const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 6); return `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; } else { return viewDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); } }; // Get the date range for filtering appointments const getDateRange = (): { start: Date; end: Date; days: Date[] } => { if (viewMode === 'day') { const start = new Date(viewDate); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); return { start, end, days: [start] }; } else if (viewMode === 'week') { const start = getStartOfWeek(viewDate); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 7); const days = Array.from({ length: 7 }, (_, i) => { const day = new Date(start); day.setDate(day.getDate() + i); return day; }); return { start, end, days }; } else { const start = getStartOfMonth(viewDate); start.setHours(0, 0, 0, 0); const end = new Date(getEndOfMonth(viewDate)); end.setDate(end.getDate() + 1); end.setHours(0, 0, 0, 0); const daysInMonth = end.getDate() - start.getDate(); const days = Array.from({ length: daysInMonth }, (_, i) => { const day = new Date(start); day.setDate(day.getDate() + i); return day; }); return { start, end, days }; } }; const handleResizeStart = ( e: React.MouseEvent, appointment: Appointment, direction: 'start' | 'end' ) => { e.preventDefault(); e.stopPropagation(); setIsResizing(true); setResizeState({ appointmentId: appointment.id, direction, startX: e.clientX, originalStart: new Date(appointment.startTime), originalDuration: appointment.durationMinutes, }); }; useEffect(() => { if (!resizeState) return; const handleMouseMove = (e: MouseEvent) => { const pixelDelta = e.clientX - resizeState.startX; const minuteDelta = pixelDelta / (PIXELS_PER_MINUTE * zoomLevel); const snappedMinutes = Math.round(minuteDelta / 15) * 15; if (snappedMinutes === 0 && resizeState.direction === 'end') return; const appointment = appointments.find(apt => apt.id === resizeState.appointmentId); if (!appointment) return; let newStart = new Date(resizeState.originalStart); let newDuration = resizeState.originalDuration; if (resizeState.direction === 'end') { newDuration = Math.max(15, resizeState.originalDuration + snappedMinutes); } else { if (resizeState.originalDuration - snappedMinutes >= 15) { newStart = new Date(resizeState.originalStart.getTime() + snappedMinutes * 60000); newDuration = resizeState.originalDuration - snappedMinutes; } } setResizeState(prev => prev ? { ...prev, newStart, newDuration } : null); }; const handleMouseUp = () => { if (resizeState && 'newStart' in resizeState && 'newDuration' in resizeState) { const appointment = appointments.find(a => a.id === resizeState.appointmentId); if (appointment) { // Add to history addToHistory({ type: 'resize', appointmentId: resizeState.appointmentId, before: { startTime: resizeState.originalStart, resourceId: appointment.resourceId, durationMinutes: resizeState.originalDuration }, after: { startTime: resizeState.newStart as Date, resourceId: appointment.resourceId, durationMinutes: resizeState.newDuration as number } }); updateMutation.mutate({ id: resizeState.appointmentId, updates: { startTime: resizeState.newStart as Date, durationMinutes: resizeState.newDuration as number } }); } } setResizeState(null); // Reset isResizing after a brief delay to prevent click handler from firing setTimeout(() => setIsResizing(false), 100); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [resizeState, zoomLevel, appointments, updateMutation]); const getOffset = (date: Date) => { const { days } = getDateRange(); // Find which day this appointment belongs to const appointmentDate = new Date(date); appointmentDate.setHours(0, 0, 0, 0); let dayIndex = 0; for (let i = 0; i < days.length; i++) { const day = new Date(days[i]); day.setHours(0, 0, 0, 0); if (day.getTime() === appointmentDate.getTime()) { dayIndex = i; break; } } // Calculate offset within the day const startOfDay = new Date(date); startOfDay.setHours(START_HOUR, 0, 0, 0); const diffMinutes = (date.getTime() - startOfDay.getTime()) / (1000 * 60); const offsetWithinDay = Math.max(0, diffMinutes * (PIXELS_PER_MINUTE * zoomLevel)); // Add the day offset const dayOffset = dayIndex * dayWidth; return dayOffset + offsetWithinDay; }; const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel); 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'; }; // Filter appointments by date range (but include all pending requests regardless of date) const { start: rangeStart, end: rangeEnd } = getDateRange(); const filteredAppointments = useMemo(() => { return appointments.filter(apt => { // Always include pending requests (no resourceId) if (!apt.resourceId) return true; // Filter scheduled appointments by date range const aptDate = new Date(apt.startTime); return aptDate >= rangeStart && aptDate < rangeEnd; }); }, [appointments, rangeStart, rangeEnd]); const resourceLayouts = useMemo(() => { return resources.map(resource => { const allResourceApps = filteredAppointments.filter(a => a.resourceId === resource.id); const layoutApps = allResourceApps.filter(a => a.id !== draggedAppointmentId); // Add preview for dragged appointment if (previewState && previewState.resourceId === resource.id && draggedAppointmentId) { const original = filteredAppointments.find(a => a.id === draggedAppointmentId); if (original) { layoutApps.push({ ...original, startTime: previewState.startTime, id: 'PREVIEW' }); } } // Apply resize state to appointments for live preview const layoutAppsWithResize = layoutApps.map(apt => { if (resizeState && apt.id === resizeState.appointmentId && resizeState.newStart && resizeState.newDuration) { return { ...apt, startTime: resizeState.newStart, durationMinutes: resizeState.newDuration }; } return apt; }); layoutAppsWithResize.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes); const lanes: number[] = []; const visibleAppointments = layoutAppsWithResize.map(apt => { const start = new Date(apt.startTime).getTime(); const end = start + apt.durationMinutes * 60000; let laneIndex = -1; for (let i = 0; i < lanes.length; i++) { if (lanes[i] <= start) { laneIndex = i; lanes[i] = end; break; } } if (laneIndex === -1) { lanes.push(end); laneIndex = lanes.length - 1; } return { ...apt, laneIndex }; }); const laneCount = Math.max(1, lanes.length); const requiredHeight = Math.max(MIN_ROW_HEIGHT, (laneCount * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP); const finalAppointments = [...visibleAppointments, ...allResourceApps.filter(a => a.id === draggedAppointmentId).map(a => ({ ...a, laneIndex: 0 }))]; return { resource, height: requiredHeight, appointments: finalAppointments, laneCount }; }); }, [filteredAppointments, draggedAppointmentId, previewState, resources, resizeState]); const handleDragStart = (e: React.DragEvent, appointmentId: string) => { if (resizeState) return e.preventDefault(); setIsDragging(true); e.dataTransfer.setData('appointmentId', appointmentId); e.dataTransfer.effectAllowed = 'move'; // Calculate where on the appointment the drag started (relative to appointment, not timeline) const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); const offsetX = e.clientX - rect.left; // Just the offset within the appointment itself const offsetMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15; setDragOffsetMinutes(offsetMinutes); setTimeout(() => setDraggedAppointmentId(appointmentId), 0); }; const handleDragEnd = () => { setDraggedAppointmentId(null); setPreviewState(null); // Reset isDragging after a short delay to allow click detection setTimeout(() => setIsDragging(false), 100); }; const handleAppointmentClick = (appointment: Appointment) => { // Only open modal if we didn't actually drag or resize if (!isDragging && !isResizing) { setSelectedAppointment(appointment); } }; const handleSaveAppointment = () => { if (!selectedAppointment) return; // Validate duration is at least 15 minutes const validDuration = editDuration >= 15 ? editDuration : 15; const updates: any = { startTime: new Date(editDateTime), durationMinutes: validDuration, }; if (editResource) { updates.resourceId = editResource; } updateMutation.mutate({ id: selectedAppointment.id, updates }); setSelectedAppointment(null); }; const handleTimelineDragOver = (e: React.DragEvent) => { if (resizeState) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (!scrollContainerRef.current || !draggedAppointmentId) return; const container = scrollContainerRef.current; const rect = container.getBoundingClientRect(); const offsetX = e.clientX - rect.left + container.scrollLeft; const offsetY = e.clientY - rect.top + container.scrollTop - HEADER_HEIGHT; if (offsetY < 0) return; let targetResourceId: string | null = null; for (let i = 0, currentTop = 0; i < resourceLayouts.length; i++) { if (offsetY >= currentTop && offsetY < currentTop + resourceLayouts[i].height) { targetResourceId = resourceLayouts[i].resource.id; break; } currentTop += resourceLayouts[i].height; } if (!targetResourceId) return; // Calculate new start time, accounting for where on the appointment the drag started const mouseMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15; const newStartMinutes = mouseMinutes - dragOffsetMinutes; const newStartTime = new Date(viewDate); newStartTime.setHours(START_HOUR, 0, 0, 0); newStartTime.setTime(newStartTime.getTime() + newStartMinutes * 60000); if (!previewState || previewState.resourceId !== targetResourceId || previewState.startTime.getTime() !== newStartTime.getTime()) { setPreviewState({ resourceId: targetResourceId, startTime: newStartTime }); } }; const handleTimelineDrop = (e: React.DragEvent) => { e.preventDefault(); if (resizeState) return; const appointmentId = e.dataTransfer.getData('appointmentId'); if (appointmentId && previewState) { const appointment = appointments.find(a => a.id === appointmentId); if (appointment) { // Add to history addToHistory({ type: 'move', appointmentId, before: { startTime: new Date(appointment.startTime), resourceId: appointment.resourceId, durationMinutes: appointment.durationMinutes }, after: { startTime: previewState.startTime, resourceId: previewState.resourceId, durationMinutes: appointment.durationMinutes } }); updateMutation.mutate({ id: appointmentId, updates: { startTime: previewState.startTime, durationMinutes: appointment.durationMinutes, resourceId: previewState.resourceId, status: appointment.status === 'PENDING' ? 'CONFIRMED' : appointment.status } }); } } setDraggedAppointmentId(null); setPreviewState(null); }; const handleDropToPending = (e: React.DragEvent) => { e.preventDefault(); const appointmentId = e.dataTransfer.getData('appointmentId'); if (appointmentId) { updateMutation.mutate({ id: appointmentId, updates: { resourceId: null, status: 'PENDING' } }); } setDraggedAppointmentId(null); setPreviewState(null); }; const handleDropToArchive = (e: React.DragEvent) => { e.preventDefault(); const appointmentId = e.dataTransfer.getData('appointmentId'); if (appointmentId) { deleteMutation.mutate(appointmentId); } setDraggedAppointmentId(null); setPreviewState(null); }; const handleSidebarDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (previewState) setPreviewState(null); }; const { days } = getDateRange(); const dayWidth = (END_HOUR - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel); const timelineWidth = dayWidth * days.length; const timeMarkers = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i); const pendingAppointments = filteredAppointments.filter(a => !a.resourceId); return (
{/* Date Navigation */}
{getDateRangeLabel()}
{/* View Mode Switcher */}
Zoom
Resources
{resourceLayouts.map(layout => (

{layout.resource.name}

{layout.resource.type.toLowerCase()} {layout.laneCount > 1 && {layout.laneCount} lanes}

))}

Pending Requests ({pendingAppointments.length})

{pendingAppointments.length === 0 && !draggedAppointmentId && (
No pending requests
)} {draggedAppointmentId && (
Drop here to unassign
)} {pendingAppointments.map(apt => { const service = services.find(s => s.id === apt.serviceId); return (
handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)} >

{apt.customerName}

{service?.name}

{formatDuration(apt.durationMinutes)}
) })}
Drop here to archive
{/* Timeline Header */}
{viewMode !== 'day' && (
{days.map((day, dayIndex) => (
{day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
))}
)}
{days.map((day, dayIndex) => (
{timeMarkers.map(hour => (
{hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
))}
))}
{/* Current time indicator - only show if current day is in view */} {days.some(day => { const today = new Date(); const dayDate = new Date(day); return today.toDateString() === dayDate.toDateString(); }) && (
)}
{/* Vertical grid lines for each day */}
{days.map((day, dayIndex) => ( {timeMarkers.map(hour => (
))}
))}
{resourceLayouts.map(layout => (
{layout.appointments.map(apt => { const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP; const service = services.find(s => s.id === apt.serviceId); return (
handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}> {!isPreview && (<>
handleResizeStart(e, apt, 'start')} />
handleResizeStart(e, apt, 'end')} />)}
{apt.customerName}
{service?.name}
{apt.status === 'COMPLETED' ? : }{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{formatDuration(apt.durationMinutes)}
); })}
))}
{/* Appointment Detail/Edit Modal */} {selectedAppointment && (
setSelectedAppointment(null)}>
e.stopPropagation()}>

{!selectedAppointment.resourceId ? 'Schedule Appointment' : 'Edit Appointment'}

{/* Customer Info */}

Customer

{selectedAppointment.customerName}

{selectedAppointment.customerEmail && (
{selectedAppointment.customerEmail}
)} {selectedAppointment.customerPhone && (
{selectedAppointment.customerPhone}
)}
{/* Service & Status */}

Service

{services.find(s => s.id === selectedAppointment.serviceId)?.name}

Status

{selectedAppointment.status.toLowerCase().replace('_', ' ')}

{/* Editable Fields */}

Schedule Details

{/* Date & Time Picker */}
setEditDateTime(e.target.value)} className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" />
{/* Resource Selector */}
{/* Duration Input */}
{ const value = parseInt(e.target.value); setEditDuration(value >= 15 ? value : 15); }} className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" />
{/* Notes */} {selectedAppointment.notes && (

Notes

{selectedAppointment.notes}

)} {/* Action Buttons */}
)}
); }; export default OwnerScheduler;