From d7cc83ebdde64dd2bec087a1d9db763fc9646cc3 Mon Sep 17 00:00:00 2001 From: poduck Date: Thu, 27 Nov 2025 20:14:48 -0500 Subject: [PATCH] feat: Implement drag-and-drop in Month View with 'Day Snap' time slot overlay --- frontend/src/pages/OwnerScheduler.tsx | 158 ++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/OwnerScheduler.tsx b/frontend/src/pages/OwnerScheduler.tsx index 0744c28..21c24ca 100644 --- a/frontend/src/pages/OwnerScheduler.tsx +++ b/frontend/src/pages/OwnerScheduler.tsx @@ -89,6 +89,7 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const [selectedAppointment, setSelectedAppointment] = useState(null); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); + const [monthDropTarget, setMonthDropTarget] = useState<{ date: Date; rect: DOMRect } | null>(null); // State for editing appointments const [editDateTime, setEditDateTime] = useState(''); @@ -529,12 +530,17 @@ const OwnerScheduler: React.FC = ({ user, business }) => { 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); + // In month view, we don't track minute offset + if (viewMode === 'month') { + setDragOffsetMinutes(0); + } else { + // 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); }; @@ -542,10 +548,63 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const handleDragEnd = () => { setDraggedAppointmentId(null); setPreviewState(null); + setMonthDropTarget(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; + 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 }); + } + }; + + const handleMonthTimeDrop = (e: React.DragEvent, targetHour: number, targetMinute: number) => { + e.preventDefault(); + e.stopPropagation(); + if (!draggedAppointmentId || !monthDropTarget) return; + + const appointment = appointments.find(a => a.id === draggedAppointmentId); + if (appointment) { + const newStartTime = new Date(monthDropTarget.date); + newStartTime.setHours(targetHour, targetMinute, 0, 0); + + // Preserve duration, keep resource + // Add to history + addToHistory({ + type: 'move', + appointmentId: appointment.id, + before: { + startTime: new Date(appointment.startTime), + resourceId: appointment.resourceId, + durationMinutes: appointment.durationMinutes + }, + after: { + startTime: newStartTime, + resourceId: appointment.resourceId, + durationMinutes: appointment.durationMinutes + } + }); + + updateMutation.mutate({ + id: appointment.id, + updates: { + startTime: newStartTime + } + }); + } + setMonthDropTarget(null); + setDraggedAppointmentId(null); + }; + const handleAppointmentClick = (appointment: Appointment) => { // Only open modal if we didn't actually drag or resize if (!isDragging && !isResizing) { @@ -831,10 +890,11 @@ const OwnerScheduler: React.FC = ({ user, business }) => { return (
{ if (date) { setViewDate(date); setViewMode('day'); } }} + onDragOver={(e) => date && handleMonthCellDragOver(e, date)} > {date && ( <> @@ -850,10 +910,15 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const service = services.find(s => s.id === apt.serviceId); const resource = resources.find(r => r.id === apt.resourceId); const startTime = new Date(apt.startTime); + const isDragged = apt.id === draggedAppointmentId; + return (
handleDragStart(e, apt.id)} + onDragEnd={handleDragEnd} onClick={(e) => { e.stopPropagation(); handleAppointmentClick(apt); }} title={`${apt.customerName} - ${service?.name} with ${resource?.name}`} > @@ -878,6 +943,79 @@ 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 +
+
+
+ {Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i).map(hour => { + // Check if we have conflicts (simple check) + const dayStart = new Date(monthDropTarget.date); + dayStart.setHours(hour, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setMinutes(59); + + const hasAppt = filteredAppointments.some(a => { + if (a.id === draggedAppointmentId) return false; // Don't count self + const t = new Date(a.startTime); + return t >= dayStart && t <= dayEnd; + }); + + return ( +
+ {/* Hour Slot */} +
handleMonthTimeDrop(e, hour, 0)} + onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} + > +
+ {hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`} +
+
+ {hasAppt &&
} +
+
+
+ {/* Half Hour Slot */} +
handleMonthTimeDrop(e, hour, 30)} + onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} + > +
+ :30 +
+
+
+
+
+
+ ); + })} +
+
+
+ )} {/* Day/Week View - Timeline */} {viewMode !== 'month' && (