diff --git a/frontend/src/hooks/useAppointments.ts b/frontend/src/hooks/useAppointments.ts index 2e81d98..3f14f55 100644 --- a/frontend/src/hooks/useAppointments.ts +++ b/frontend/src/hooks/useAppointments.ts @@ -136,7 +136,8 @@ export const useUpdateAppointment = () => { if (updates.serviceId) backendData.service = parseInt(updates.serviceId); if (updates.resourceId !== undefined) { - backendData.resource = updates.resourceId ? parseInt(updates.resourceId) : null; + // Backend expects resource_ids as a list + backendData.resource_ids = updates.resourceId ? [parseInt(updates.resourceId)] : []; } if (updates.startTime) { backendData.start_time = updates.startTime.toISOString(); diff --git a/frontend/src/pages/OwnerScheduler.tsx b/frontend/src/pages/OwnerScheduler.tsx index 77160df..822c89e 100644 --- a/frontend/src/pages/OwnerScheduler.tsx +++ b/frontend/src/pages/OwnerScheduler.tsx @@ -3,8 +3,8 @@ */ 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 { Appointment, AppointmentStatus, User, Business } from '../types'; +import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight, Check } from 'lucide-react'; import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments'; import { useResources } from '../hooks/useResources'; import { useServices } from '../hooks/useServices'; @@ -16,6 +16,7 @@ const START_HOUR = 0; // Midnight const END_HOUR = 24; // Midnight next day const PIXELS_PER_MINUTE = 2.5; const OVERLAY_HOUR_HEIGHT = 60; +const OVERLAY_RESOURCE_WIDTH = 150; // Width of each resource column in month overlay const HEADER_HEIGHT = 48; const SIDEBAR_WIDTH = 250; @@ -91,12 +92,23 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [monthDropTarget, setMonthDropTarget] = useState<{ date: Date; rect: DOMRect } | null>(null); + const [overlayPreview, setOverlayPreview] = useState<{ resourceId: string; hour: number; minute: number } | null>(null); + const overlayAutoScrollRef = useRef(null); + const monthOverlayDelayRef = useRef(null); + const pendingMonthDropRef = useRef<{ date: Date; rect: DOMRect } | null>(null); // State for editing appointments const [editDateTime, setEditDateTime] = useState(''); const [editResource, setEditResource] = useState(''); const [editDuration, setEditDuration] = useState(0); + // Filter state + const [showFilterMenu, setShowFilterMenu] = useState(false); + const [filterStatuses, setFilterStatuses] = useState>(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'])); + const [filterResources, setFilterResources] = useState>(new Set()); // Empty means all + const [filterServices, setFilterServices] = useState>(new Set()); // Empty means all + const filterMenuRef = useRef(null); + // Update edit state when selected appointment changes useEffect(() => { if (selectedAppointment) { @@ -118,6 +130,8 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const scrollContainerRef = useRef(null); const overlayScrollRef = useRef(null); // Ref for the MonthDropOverlay's scrollable area + const overlayContainerRef = useRef(null); // Ref for the overlay container (for wheel events) + const isOverOverlayRef = useRef(false); // Track if mouse is over the month drop overlay // Keyboard shortcuts for undo/redo useEffect(() => { @@ -135,6 +149,87 @@ const OwnerScheduler: React.FC = ({ user, business }) => { return () => window.removeEventListener('keydown', handleKeyDown); }, [historyIndex, history]); + // Handle wheel events on month drop overlay for horizontal scrolling + // Use a callback ref pattern to attach the listener when the element is available + const overlayWheelHandler = React.useCallback((e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (overlayScrollRef.current) { + // Convert vertical scroll to horizontal: scroll up = scroll left, scroll down = scroll right + overlayScrollRef.current.scrollLeft += e.deltaY; + // Sync the header + const header = overlayScrollRef.current.previousElementSibling as HTMLElement; + if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft; + } + }, []); + + // Callback ref that attaches the wheel listener when the overlay mounts + const overlayContainerCallbackRef = React.useCallback((node: HTMLDivElement | null) => { + // Remove listener from previous node if any + if (overlayContainerRef.current) { + overlayContainerRef.current.removeEventListener('wheel', overlayWheelHandler); + } + // Store the new ref + overlayContainerRef.current = node; + // Add listener to new node + if (node) { + node.addEventListener('wheel', overlayWheelHandler, { passive: false }); + } + }, [overlayWheelHandler]); + + // Close filter menu when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) { + setShowFilterMenu(false); + } + }; + + if (showFilterMenu) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showFilterMenu]); + + // Filter toggle helpers + const toggleStatusFilter = (status: AppointmentStatus) => { + const newSet = new Set(filterStatuses); + if (newSet.has(status)) { + newSet.delete(status); + } else { + newSet.add(status); + } + setFilterStatuses(newSet); + }; + + const toggleResourceFilter = (resourceId: string) => { + const newSet = new Set(filterResources); + if (newSet.has(resourceId)) { + newSet.delete(resourceId); + } else { + newSet.add(resourceId); + } + setFilterResources(newSet); + }; + + const toggleServiceFilter = (serviceId: string) => { + const newSet = new Set(filterServices); + if (newSet.has(serviceId)) { + newSet.delete(serviceId); + } else { + newSet.add(serviceId); + } + setFilterServices(newSet); + }; + + const clearAllFilters = () => { + setFilterStatuses(new Set(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'])); + setFilterResources(new Set()); + setFilterServices(new Set()); + }; + + const hasActiveFilters = filterStatuses.size < 5 || filterResources.size > 0 || filterServices.size > 0; + // Scroll to current time on mount (centered in view) useEffect(() => { if (!scrollContainerRef.current) return; @@ -454,7 +549,8 @@ const OwnerScheduler: React.FC = ({ user, business }) => { 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 === 'COMPLETED') return 'bg-green-100 border-green-500 text-green-900 dark:bg-green-900/50 dark:border-green-500 dark:text-green-200'; + if (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'; @@ -462,18 +558,38 @@ const OwnerScheduler: React.FC = ({ user, business }) => { 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) + // Simplified status colors for month view (no border classes) + const getMonthStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => { + if (status === 'COMPLETED') return 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800/50'; + if (status === 'NO_SHOW') return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'; + if (status === 'CANCELLED') return 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-75 hover:bg-gray-200 dark:hover:bg-gray-600'; + const now = new Date(); + if (now > endTime) return 'bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800/50'; + if (now >= startTime && now <= endTime) return 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200 hover:bg-yellow-200 dark:hover:bg-yellow-800/50'; + return 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800/50'; + }; + + // Filter appointments by date range and user filters const { start: rangeStart, end: rangeEnd } = getDateRange(); const filteredAppointments = useMemo(() => { return appointments.filter(apt => { - // Always include pending requests (no resourceId) + // Apply status filter + if (!filterStatuses.has(apt.status)) return false; + + // Apply resource filter (empty set means show all) + if (filterResources.size > 0 && apt.resourceId && !filterResources.has(apt.resourceId)) return false; + + // Apply service filter (empty set means show all) + if (filterServices.size > 0 && !filterServices.has(apt.serviceId)) return false; + + // Always include pending requests (no resourceId) if they pass above filters 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]); + }, [appointments, rangeStart, rangeEnd, filterStatuses, filterResources, filterServices]); const resourceLayouts = useMemo(() => { return resources.map(resource => { @@ -551,25 +667,63 @@ const OwnerScheduler: React.FC = ({ user, business }) => { setDraggedAppointmentId(null); setPreviewState(null); setMonthDropTarget(null); + setOverlayPreview(null); + isOverOverlayRef.current = false; + pendingMonthDropRef.current = null; + // Clear any pending overlay delay timeout + if (monthOverlayDelayRef.current) { + clearTimeout(monthOverlayDelayRef.current); + monthOverlayDelayRef.current = null; + } + // Clear any auto-scroll interval + if (overlayAutoScrollRef.current) { + clearInterval(overlayAutoScrollRef.current); + overlayAutoScrollRef.current = null; + } // Reset isDragging after a short delay to allow click detection setTimeout(() => setIsDragging(false), 100); }; const handleMonthCellDragOver = (e: React.DragEvent, date: Date) => { if (!draggedAppointmentId) return; + // Don't update target if mouse is over the overlay + if (isOverOverlayRef.current) return; + e.preventDefault(); e.stopPropagation(); - - // Debounce the state update slightly or just check if changed + const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); - - if (!monthDropTarget || monthDropTarget.date.getTime() !== date.getTime()) { - setMonthDropTarget({ date, rect }); + + // If we're already showing the overlay for this date, do nothing + if (monthDropTarget && monthDropTarget.date.getTime() === date.getTime()) { + return; + } + + // If we moved to a different date, cancel any pending timeout and clear overlay + if (pendingMonthDropRef.current && pendingMonthDropRef.current.date.getTime() !== date.getTime()) { + if (monthOverlayDelayRef.current) { + clearTimeout(monthOverlayDelayRef.current); + monthOverlayDelayRef.current = null; + } + setMonthDropTarget(null); + } + + // Store the pending drop target + pendingMonthDropRef.current = { date, rect }; + + // Start a new timeout if not already pending for this date + if (!monthOverlayDelayRef.current) { + monthOverlayDelayRef.current = setTimeout(() => { + if (pendingMonthDropRef.current) { + setMonthDropTarget(pendingMonthDropRef.current); + } + monthOverlayDelayRef.current = null; + }, 1000); // 1 second delay } }; - const handleMonthTimeDrop = (e: React.DragEvent, targetHour: number, targetMinute: number) => { + const handleMonthTimeDrop = (e: React.DragEvent, targetHour: number, targetMinute: number, targetResourceId?: string) => { e.preventDefault(); e.stopPropagation(); if (!draggedAppointmentId || !monthDropTarget) return; @@ -578,8 +732,10 @@ const OwnerScheduler: React.FC = ({ user, business }) => { if (appointment) { const newStartTime = new Date(monthDropTarget.date); newStartTime.setHours(targetHour, targetMinute, 0, 0); - - // Preserve duration, keep resource + + // Use target resource if provided, otherwise keep existing + const newResourceId = targetResourceId !== undefined ? targetResourceId : appointment.resourceId; + // Add to history addToHistory({ type: 'move', @@ -591,7 +747,7 @@ const OwnerScheduler: React.FC = ({ user, business }) => { }, after: { startTime: newStartTime, - resourceId: appointment.resourceId, + resourceId: newResourceId, durationMinutes: appointment.durationMinutes } }); @@ -599,29 +755,54 @@ const OwnerScheduler: React.FC = ({ user, business }) => { updateMutation.mutate({ id: appointment.id, updates: { - startTime: newStartTime + startTime: newStartTime, + resourceId: newResourceId, + durationMinutes: appointment.durationMinutes // Required to calculate end_time } }); } setMonthDropTarget(null); setDraggedAppointmentId(null); + setOverlayPreview(null); + isOverOverlayRef.current = false; }; - const handleOverlayScroll = (e: React.DragEvent) => { + const handleOverlayAutoScroll = (e: React.DragEvent) => { if (!overlayScrollRef.current) return; const rect = overlayScrollRef.current.getBoundingClientRect(); - const mouseY = e.clientY; + const mouseX = e.clientX; + const scrollThreshold = 50; // pixels from edge to start scrolling + const scrollSpeed = 8; // pixels per scroll step - const scrollThreshold = 30; // pixels from edge to start scrolling - const scrollSpeed = 10; // pixels per scroll step + // Clear any existing auto-scroll + if (overlayAutoScrollRef.current) { + clearInterval(overlayAutoScrollRef.current); + overlayAutoScrollRef.current = null; + } - if (mouseY < rect.top + scrollThreshold && overlayScrollRef.current.scrollTop > 0) { - // Scroll up - overlayScrollRef.current.scrollTop -= scrollSpeed; - } else if (mouseY > rect.bottom - scrollThreshold && overlayScrollRef.current.scrollTop < overlayScrollRef.current.scrollHeight - overlayScrollRef.current.clientHeight) { - // Scroll down - overlayScrollRef.current.scrollTop += scrollSpeed; + // Start auto-scrolling if near left or right edge + if (mouseX < rect.left + scrollThreshold && overlayScrollRef.current.scrollLeft > 0) { + // Auto-scroll left + overlayAutoScrollRef.current = setInterval(() => { + if (overlayScrollRef.current) { + overlayScrollRef.current.scrollLeft -= scrollSpeed; + // Sync header + const header = overlayScrollRef.current.previousElementSibling as HTMLElement; + if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft; + } + }, 16); + } else if (mouseX > rect.right - scrollThreshold && + overlayScrollRef.current.scrollLeft < overlayScrollRef.current.scrollWidth - overlayScrollRef.current.clientWidth) { + // Auto-scroll right + overlayAutoScrollRef.current = setInterval(() => { + if (overlayScrollRef.current) { + overlayScrollRef.current.scrollLeft += scrollSpeed; + // Sync header + const header = overlayScrollRef.current.previousElementSibling as HTMLElement; + if (header) header.scrollLeft = overlayScrollRef.current.scrollLeft; + } + }, 16); } }; @@ -847,14 +1028,147 @@ const OwnerScheduler: React.FC = ({ user, business }) => { + {/* Status Legend */} +
+ Status: +
+
+
+ Upcoming +
+
+
+ In Progress +
+
+
+ Overdue +
+
+
+ Completed +
+
+
+ Cancelled +
+
+
- + {/* Filter Dropdown */} +
+ + {showFilterMenu && ( +
+
+

Filters

+ {hasActiveFilters && ( + + )} +
+
+ {/* Status Filter */} +
+

Status

+
+ {(['PENDING', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as AppointmentStatus[]).map(status => ( +
toggleStatusFilter(status)} + className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded" + > +
+ {filterStatuses.has(status) && } +
+ + {status.toLowerCase().replace('_', ' ')} + +
+
+ ))} +
+
+ {/* Resource Filter */} +
+

Resources

+
+ {resources.map(resource => ( +
toggleResourceFilter(resource.id)} + className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded" + > +
+ {(filterResources.size === 0 || filterResources.has(resource.id)) && } +
+ {resource.name} + {resource.type.toLowerCase()} +
+ ))} +
+
+ {/* Service Filter */} +
+

Services

+
+ {services.map(service => ( +
toggleServiceFilter(service.id)} + className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 px-2 -mx-2 rounded" + > +
+ {(filterServices.size === 0 || filterServices.has(service.id)) && } +
+ {service.name} + {service.durationMinutes}min +
+ ))} +
+
+
+
+ )} +
@@ -910,9 +1224,13 @@ const OwnerScheduler: React.FC = ({ user, business }) => { return (
{ if (date) { setViewDate(date); setViewMode('day'); } }} onDragOver={(e) => date && handleMonthCellDragOver(e, date)} > @@ -932,10 +1250,12 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const startTime = new Date(apt.startTime); const isDragged = apt.id === draggedAppointmentId; + const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); + return (
handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} @@ -964,135 +1284,269 @@ const OwnerScheduler: React.FC = ({ user, business }) => {
)} - {/* Month View Drop Overlay */} - {monthDropTarget && draggedAppointmentId && ( - -
{ e.preventDefault(); e.stopPropagation(); }} // Consume drag over - > -
- - Move to {monthDropTarget.date.getDate()} - -
- Drop in slot + {/* Month View Drop Overlay - Mini Day Scheduler with Resource Rows */} + {monthDropTarget && draggedAppointmentId && (() => { + // Pre-calculate resource layouts with lanes for this day + const dayStart = new Date(monthDropTarget.date); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setHours(23, 59, 59, 999); + + const OVERLAY_ROW_HEIGHT = 50; // Height per lane in overlay + const OVERLAY_LANE_GAP = 2; + const OVERLAY_PIXELS_PER_MINUTE = 1.5; + const overlayTimelineWidth = (END_HOUR - START_HOUR) * 60 * OVERLAY_PIXELS_PER_MINUTE; + + // Get the dragged appointment for preview calculations + const draggedApt = appointments.find(a => a.id === draggedAppointmentId); + + const overlayResourceLayouts = resources.map(resource => { + // Get existing appointments for this resource on this day (excluding the dragged one) + let resourceApps = appointments.filter(apt => { + if (apt.resourceId !== resource.id || apt.id === draggedAppointmentId) return false; + const t = new Date(apt.startTime); + return t >= dayStart && t <= dayEnd; + }); + + // Add preview appointment if hovering over this resource + if (overlayPreview?.resourceId === resource.id && draggedApt) { + const previewStartMinutes = (overlayPreview.hour - START_HOUR) * 60 + overlayPreview.minute; + resourceApps = [...resourceApps, { + ...draggedApt, + id: 'PREVIEW', + startTime: new Date(dayStart.getTime() + previewStartMinutes * 60000), + }]; + } + + // Sort by start time, then by duration (longer first) + resourceApps.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes); + + // Calculate lanes (respecting maxConcurrentEvents) + const lanes: number[] = []; + const maxLanes = resource.maxConcurrentEvents === 0 ? Infinity : resource.maxConcurrentEvents; + const laidOutApps = resourceApps.map(apt => { + const start = new Date(apt.startTime); + const startMinutes = (start.getHours() - START_HOUR) * 60 + start.getMinutes(); + const endMinutes = startMinutes + apt.durationMinutes; + + let laneIndex = -1; + for (let i = 0; i < Math.min(lanes.length, maxLanes); i++) { + if (lanes[i] <= startMinutes) { + laneIndex = i; + lanes[i] = endMinutes; + break; + } + } + if (laneIndex === -1 && lanes.length < maxLanes) { + lanes.push(endMinutes); + laneIndex = lanes.length - 1; + } else if (laneIndex === -1) { + laneIndex = Math.min(lanes.length - 1, maxLanes - 1); + } + + return { ...apt, laneIndex, startMinutes, endMinutes, isPreview: apt.id === 'PREVIEW' }; + }); + + const laneCount = Math.max(1, Math.min(lanes.length, maxLanes === Infinity ? lanes.length : maxLanes)); + const rowHeight = laneCount * OVERLAY_ROW_HEIGHT + OVERLAY_LANE_GAP; + + return { resource, appointments: laidOutApps, laneCount, rowHeight }; + }); + + const totalRowsHeight = overlayResourceLayouts.reduce((sum, r) => sum + r.rowHeight, 0); + + return ( + +
{ e.preventDefault(); e.stopPropagation(); }} + onDragEnter={() => { isOverOverlayRef.current = true; }} + onDragLeave={(e) => { + // Only set to false if we're actually leaving the overlay (not entering a child) + const rect = e.currentTarget.getBoundingClientRect(); + if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) { + isOverOverlayRef.current = false; + } + }} + > + {/* Header */} +
+ + {monthDropTarget.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} + +
+ Drop on resource row +
-
-
- {/* Background Slots */} -
- {Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => ( -
- {/* Hour Slot (Top Half) */} -
handleMonthTimeDrop(e, hour, 0)} - onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} - > -
- {hour === 0 ? '12 AM' : hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`} -
-
-
-
-
- {/* Half Hour Slot (Bottom Half) */} -
handleMonthTimeDrop(e, hour, 30)} - onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} - > -
-
-
+ + {/* Main content area */} +
+ {/* Resource names sidebar - fixed */} +
+ {/* Corner cell above resource names */} +
+ {/* Resource names - synced scroll */} +
{ + if (el && overlayScrollRef.current) { + el.scrollTop = overlayScrollRef.current.scrollTop; + } + }}> + {overlayResourceLayouts.map(layout => ( +
+
+ + {layout.resource.name} + + {layout.laneCount > 1 && ( + + {layout.laneCount} lanes + + )} +
+ ))} +
+
+ + {/* Timeline area - scrollable both ways */} +
+ {/* Time header - horizontal scroll only */} +
+
+ {Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => ( +
+ {hour === 0 ? '12a' : hour === 12 ? '12p' : hour > 12 ? `${hour - 12}p` : `${hour}a`} +
+ ))}
- ))} - - {/* Existing Appointments Layer */} -
- {(() => { - // Calculate layout for this day - const dayStart = new Date(monthDropTarget.date); - dayStart.setHours(0, 0, 0, 0); - const dayEnd = new Date(dayStart); - dayEnd.setHours(23, 59, 59, 999); - const dayApps = appointments.filter(apt => { - if (!apt.resourceId || apt.id === draggedAppointmentId) return false; - const t = new Date(apt.startTime); - return t >= dayStart && t <= dayEnd; - }).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes); + {/* Timeline grid with appointments */} +
{ + e.preventDefault(); + handleOverlayAutoScroll(e); + }} onScroll={(e) => { + // Sync horizontal scroll with header + const header = e.currentTarget.previousElementSibling as HTMLElement; + if (header) header.scrollLeft = e.currentTarget.scrollLeft; + }}> +
+ {overlayResourceLayouts.map((layout) => ( +
+ {/* Hour grid lines */} + {Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => ( + + {/* Hour line */} +
+ {/* Half-hour line */} +
+
+ ))} - // Calculate lanes - const lanes: number[] = []; - const laidOutApps = dayApps.map(apt => { - const start = new Date(apt.startTime); - const startMinutes = (start.getHours() * 60) + start.getMinutes(); - const endMinutes = startMinutes + apt.durationMinutes; - - let laneIndex = -1; - for (let i = 0; i < lanes.length; i++) { - if (lanes[i] <= startMinutes) { - laneIndex = i; - lanes[i] = endMinutes; - break; - } - } - if (laneIndex === -1) { - lanes.push(endMinutes); - laneIndex = lanes.length - 1; - } - - return { ...apt, laneIndex, startMinutes, endMinutes }; - }); + {/* Drop zones for each half-hour */} + {Array.from({ length: (END_HOUR - START_HOUR) * 2 }, (_, i) => { + const hour = START_HOUR + Math.floor(i / 2); + const minute = (i % 2) * 30; + return ( +
handleMonthTimeDrop(e, hour, minute, layout.resource.id)} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + // Set preview state + if (!overlayPreview || overlayPreview.resourceId !== layout.resource.id || + overlayPreview.hour !== hour || overlayPreview.minute !== minute) { + setOverlayPreview({ resourceId: layout.resource.id, hour, minute }); + } + }} + onDragLeave={() => { + // Only clear if leaving this specific zone + if (overlayPreview?.resourceId === layout.resource.id && + overlayPreview?.hour === hour && overlayPreview?.minute === minute) { + // Don't clear immediately - let dragover on another zone set it + } + }} + >
+ ); + })} - const totalLanes = Math.max(1, lanes.length); + {/* Appointments (including preview) */} + {layout.appointments.map(apt => { + const left = apt.startMinutes * OVERLAY_PIXELS_PER_MINUTE; + const width = apt.durationMinutes * OVERLAY_PIXELS_PER_MINUTE; + const top = apt.laneIndex * OVERLAY_ROW_HEIGHT + OVERLAY_LANE_GAP; + const service = services.find(s => s.id === apt.serviceId); + const startTime = new Date(apt.startTime); + const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); + const isPreview = apt.isPreview; - return laidOutApps.map(apt => { - const top = (apt.startMinutes / 60) * OVERLAY_HOUR_HEIGHT; - const height = (apt.durationMinutes / 60) * OVERLAY_HOUR_HEIGHT; - const widthPercent = 100 / totalLanes; - const leftPercent = apt.laneIndex * widthPercent; - const service = services.find(s => s.id === apt.serviceId); - - return ( -
-
- {apt.customerName} -
-
- {service?.name} -
-
- ); - }); - })()} -
+ return ( +
+
+ {apt.customerName} +
+
+ {service?.name} +
+
+ ); + })} +
+ ))} +
+
+
-
- - )} + + ); + })()} {/* Day/Week View - Timeline */} {viewMode !== 'month' && ( diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py index db92ada..2cde9c4 100644 --- a/smoothschedule/schedule/serializers.py +++ b/smoothschedule/schedule/serializers.py @@ -82,7 +82,7 @@ class ResourceSerializer(serializers.ModelSerializer): fields = [ 'id', 'name', 'type', 'description', 'max_concurrent_events', 'buffer_duration', 'is_active', 'capacity_description', - 'created_at', 'updated_at', + 'saved_lane_count', 'created_at', 'updated_at', ] read_only_fields = ['created_at', 'updated_at'] @@ -196,16 +196,28 @@ class EventSerializer(serializers.ModelSerializer): def validate(self, attrs): """ Validate event timing and resource availability. - + Checks: 1. end_time > start_time 2. start_time not in past (for new events) 3. Resource availability using AvailabilityService """ + # For partial updates, get existing values from instance if not provided start_time = attrs.get('start_time') end_time = attrs.get('end_time') resource_ids = attrs.get('resource_ids', []) - + + # If this is a partial update, fill in missing values from existing instance + if self.instance: + if start_time is None: + start_time = self.instance.start_time + if end_time is None: + end_time = self.instance.end_time + + # Skip validation if we still don't have both times (shouldn't happen for valid requests) + if start_time is None or end_time is None: + return attrs + # Validation 1: End must be after start if end_time <= start_time: raise serializers.ValidationError({ diff --git a/smoothschedule/schedule/services.py b/smoothschedule/schedule/services.py index 3cefd42..25bdabd 100644 --- a/smoothschedule/schedule/services.py +++ b/smoothschedule/schedule/services.py @@ -48,7 +48,8 @@ class AvailabilityService: event = participant.event # Skip if this is the event being updated - if exclude_event_id and event.id == exclude_event_id: + # CRITICAL: Convert exclude_event_id to int for comparison (frontend may send string) + if exclude_event_id and event.id == int(exclude_event_id): continue # CRITICAL: Skip cancelled events (prevents ghost bookings)