diff --git a/frontend/src/components/pages/Calendar.vue b/frontend/src/components/pages/Calendar.vue index 54e912b..68cb38a 100644 --- a/frontend/src/components/pages/Calendar.vue +++ b/frontend/src/components/pages/Calendar.vue @@ -55,8 +55,14 @@ class="day-column" :class="{ 'current-time': isCurrentTimeSlot(day.date, timeSlot.time), + 'drag-over': isDragOver && dragOverSlot?.date === day.date && dragOverSlot?.time === timeSlot.time && !dragOverSlot?.isOccupied, + 'drag-over-occupied': isDragOver && dragOverSlot?.date === day.date && dragOverSlot?.time === timeSlot.time && dragOverSlot?.isOccupied, + 'drag-preview': isDragOver && isInPreviewSlots(day.date, timeSlot.time), }" @click="selectTimeSlot(day.date, timeSlot.time)" + @dragover="handleDragOver($event, day.date, timeSlot.time)" + @dragleave="handleDragLeave" + @drop="handleDrop($event, day.date, timeSlot.time)" >
{{ event.serviceType }}
{{ event.customer }}
@@ -89,7 +100,13 @@
-
+
@@ -220,6 +240,12 @@ const currentWeekStart = ref(getWeekStart(new Date("2025-10-25"))); const eventDialog = ref(false); const selectedEvent = ref(null); +// Drag and drop state +const draggedService = ref(null); +const isDragOver = ref(false); +const dragOverSlot = ref(null); +const previewSlots = ref([]); + // Helper function to get week start (Monday) function getWeekStart(date) { const d = new Date(date); @@ -345,6 +371,52 @@ const getEndTime = (startTime, durationMinutes) => { return endDate.toTimeString().slice(0, 5); }; +// Check if a time slot has any conflicts with existing events +const isTimeSlotOccupied = (date, startTime, durationMinutes, excludeServiceId = null) => { + const endTime = getEndTime(startTime, durationMinutes); + + return scheduledServices.value.some((service) => { + // Exclude the service being moved + if (excludeServiceId && service.id === excludeServiceId) return false; + + if (service.scheduledDate !== date) return false; + + const serviceEndTime = getEndTime(service.scheduledTime, service.duration); + + // Check for time overlap + return ( + (startTime >= service.scheduledTime && startTime < serviceEndTime) || + (endTime > service.scheduledTime && endTime <= serviceEndTime) || + (startTime <= service.scheduledTime && endTime >= serviceEndTime) + ); + }); +}; + +// Get all time slots that would be occupied by an event +const getOccupiedSlots = (date, startTime, durationMinutes) => { + const slots = []; + const endTime = getEndTime(startTime, durationMinutes); + + // Generate all 30-minute slots between start and end time + let currentTime = startTime; + while (currentTime < endTime) { + slots.push({ date, time: currentTime }); + + // Add 30 minutes + const [hours, minutes] = currentTime.split(':').map(Number); + const nextMinutes = minutes + 30; + const nextHours = hours + Math.floor(nextMinutes / 60); + const finalMinutes = nextMinutes % 60; + + currentTime = `${nextHours.toString().padStart(2, '0')}:${finalMinutes.toString().padStart(2, '0')}`; + + // Safety check to prevent infinite loop + if (nextHours >= 24) break; + } + + return slots; +}; + // Calendar navigation methods const previousWeek = () => { const newDate = new Date(currentWeekStart.value); @@ -367,11 +439,8 @@ const getEventsForTimeSlot = (date, time) => { return scheduledServices.value.filter((service) => { if (service.scheduledDate !== date) return false; - const serviceTime = service.scheduledTime; - const serviceEndTime = getEndTime(service.scheduledTime, service.duration); - - // Check if this time slot falls within the service duration - return time >= serviceTime && time < serviceEndTime; + // Only show the event in its starting time slot to prevent duplication + return service.scheduledTime === time; }); }; @@ -399,6 +468,10 @@ const selectTimeSlot = (date, time) => { // This will be used for drag-and-drop functionality }; +const isInPreviewSlots = (date, time) => { + return previewSlots.value.some(slot => slot.date === date && slot.time === time); +}; + const showEventDetails = (event) => { selectedEvent.value = event.event; eventDialog.value = true; @@ -413,6 +486,179 @@ const scheduleService = (service) => { ); }; +// Drag and Drop methods +const handleDragStart = (service, event) => { + draggedService.value = service; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", service.id); + + // Get the dimensions of the dragged element + const dragElement = event.target; + const rect = dragElement.getBoundingClientRect(); + + // Set the drag image offset to center the element horizontally and position cursor at top + const offsetX = rect.width / 2; + const offsetY = 10; // Position cursor near the top of the element + + try { + event.dataTransfer.setDragImage(dragElement, offsetX, offsetY); + } catch (e) { + // Fallback if setDragImage fails + console.log("Could not set custom drag image"); + } + + // Add visual feedback + event.target.style.opacity = '0.5'; + console.log("Drag started for service:", service.serviceType); +}; + +const handleDragEnd = (event) => { + // Reset visual feedback + event.target.style.opacity = '1'; + draggedService.value = null; + isDragOver.value = false; + dragOverSlot.value = null; + previewSlots.value = []; +}; + +const handleDragOver = (event, date, time) => { + event.preventDefault(); + event.stopPropagation(); // Prevent event bubbling to avoid conflicts with nested elements + + if (!draggedService.value) return; + + // Check if the slot would be occupied (excluding the current service) + const isOccupied = isTimeSlotOccupied(date, time, draggedService.value.duration, draggedService.value.id); + + // Calculate which slots would be occupied by this event + const wouldOccupySlots = getOccupiedSlots(date, time, draggedService.value.duration); + + event.dataTransfer.dropEffect = isOccupied ? "none" : "move"; + + // Only update state if it's actually different to prevent flickering + const currentSlotKey = `${date}-${time}`; + const previousSlotKey = dragOverSlot.value ? `${dragOverSlot.value.date}-${dragOverSlot.value.time}` : null; + + if (currentSlotKey !== previousSlotKey || dragOverSlot.value?.isOccupied !== isOccupied) { + isDragOver.value = true; + dragOverSlot.value = { date, time, isOccupied }; + previewSlots.value = wouldOccupySlots; + } +}; + +const handleDragLeave = (event) => { + // Use a small delay to prevent flickering when moving between child elements + setTimeout(() => { + // Check if we're still dragging and if the mouse has actually left the calendar area + if (!draggedService.value) return; + + const calendarGrid = document.querySelector('.calendar-grid'); + if (!calendarGrid) return; + + const rect = calendarGrid.getBoundingClientRect(); + const x = event.clientX; + const y = event.clientY; + + // Only clear if mouse is outside the entire calendar grid + if ( + x < rect.left || + x > rect.right || + y < rect.top || + y > rect.bottom + ) { + isDragOver.value = false; + dragOverSlot.value = null; + previewSlots.value = []; + } + }, 10); +}; + +const handleDrop = (event, date, time) => { + event.preventDefault(); + event.stopPropagation(); + + if (!draggedService.value) return; + + // Check if the slot is occupied (excluding the service being moved) + const isOccupied = isTimeSlotOccupied(date, time, draggedService.value.duration, draggedService.value.id); + + if (isOccupied) { + console.log("Cannot drop here - time slot is occupied"); + // Reset drag state + isDragOver.value = false; + dragOverSlot.value = null; + previewSlots.value = []; + draggedService.value = null; + return; + } + + // Convert time to 12-hour format for display + const [hours, minutes] = time.split(':').map(Number); + const displayHour = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours; + const ampm = hours >= 12 ? 'PM' : 'AM'; + const formattedTime = `${displayHour}:${minutes.toString().padStart(2, '0')} ${ampm}`; + + // Update the service with scheduling information + const serviceIndex = services.value.findIndex(s => s.id === draggedService.value.id); + if (serviceIndex !== -1) { + const wasScheduled = services.value[serviceIndex].status === 'scheduled'; + services.value[serviceIndex] = { + ...services.value[serviceIndex], + status: 'scheduled', + scheduledDate: date, + scheduledTime: time, + scheduledTimeDisplay: formattedTime + }; + + const action = wasScheduled ? 'Moved' : 'Scheduled'; + console.log(`${action} ${draggedService.value.serviceType} for ${date} at ${formattedTime}`); + } + + // Reset drag state + isDragOver.value = false; + dragOverSlot.value = null; + previewSlots.value = []; + draggedService.value = null; +}; + +// Handle dropping scheduled items back to unscheduled +const handleUnscheduledDragOver = (event) => { + if (draggedService.value?.status === 'scheduled') { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + } +}; + +const handleUnscheduledDragLeave = (event) => { + // Visual feedback will be handled by CSS +}; + +const handleUnscheduledDrop = (event) => { + event.preventDefault(); + + if (!draggedService.value || draggedService.value.status !== 'scheduled') return; + + // Update the service to unscheduled status + const serviceIndex = services.value.findIndex(s => s.id === draggedService.value.id); + if (serviceIndex !== -1) { + services.value[serviceIndex] = { + ...services.value[serviceIndex], + status: 'unscheduled', + scheduledDate: null, + scheduledTime: null, + scheduledTimeDisplay: null + }; + + console.log(`Unscheduled ${draggedService.value.serviceType}`); + } + + // Reset drag state + isDragOver.value = false; + dragOverSlot.value = null; + previewSlots.value = []; + draggedService.value = null; +}; + // Lifecycle onMounted(async () => { try { @@ -551,6 +797,25 @@ onMounted(async () => { background-color: #fff3e0; } +.day-column.drag-over { + background-color: #e8f5e8; + border: 2px dashed #4caf50; + box-sizing: border-box; +} + +.day-column.drag-over-occupied { + background-color: #ffebee; + border: 2px dashed #f44336; + box-sizing: border-box; +} + +.day-column.drag-preview { + background-color: #e3f2fd; + border: 1px solid #2196f3; + box-sizing: border-box; + opacity: 0.7; +} + .calendar-event { position: absolute; left: 2px; @@ -573,6 +838,14 @@ onMounted(async () => { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); } +.calendar-event[draggable="true"] { + cursor: grab; +} + +.calendar-event[draggable="true"]:active { + cursor: grabbing; +} + .calendar-event .event-title { font-weight: 600; white-space: nowrap; @@ -634,6 +907,14 @@ onMounted(async () => { flex: 1; overflow-y: auto; max-height: calc(100vh - 200px); + transition: all 0.3s ease; + border-radius: 8px; +} + +.unscheduled-list.unscheduled-drop-zone { + background-color: #e8f5e8; + border: 2px dashed #4caf50; + box-sizing: border-box; } .unscheduled-item { @@ -645,6 +926,14 @@ onMounted(async () => { transform: translateX(3px); } +.unscheduled-item[draggable="true"] { + cursor: grab; +} + +.unscheduled-item[draggable="true"]:active { + cursor: grabbing; +} + .priority-urgent { border-left-color: #f44336 !important; }